// dashboard-bulk-enrich-runner-v63.js — extracted verbatim from dashboard.js (pure move).

// ── Bulk enrich runner (v63) ─────────────────────────────────────
// Hits POST /api/crm/{companies|contacts}/enrich/bulk, opens a progress
// widget tuned for enrichment counters (employees discovered, channels
// added, deferred, failed) and polls until the runner reports completion.

async function runBulkEnrichFromSelection() {
  const sel = dashboardState.crmSelection;
  if (!sel.type || sel.ids.size === 0) return;
  const ids = [...sel.ids];
  const action =
    sel.type === "companies" ? "crm-companies-enrich-bulk" : "crm-contacts-enrich-bulk";

  // Open the widget IMMEDIATELY in a "starting…" state so the click feels
  // responsive even if the API takes 5-15s to return (large bulk jobs do
  // 50+ INSERT/SELECT round-trips for queueing). Without this, the button
  // looks dead until the API answers.
  const placeholder = {
    id: "_starting",
    total: ids.length,
    processed: 0,
    employees_found: 0,
    channels_added: 0,
    deferred: 0,
    failed: 0,
    tasks_completed: 0,
    tasks_failed: 0,
    tasks_running: 0,
    tasks_pending: 0,
    recent_errors: [],
    status: "running",
  };
  const widget = _openBulkEnrichWidget(placeholder, sel.type);

  const start = await api(action, { body: { ids } }).catch((e) => ({ error: e?.message }));
  if (!start || start.error || !start.id) {
    widget.update({ ...placeholder, _error: start?.error || "failed to start" });
    toast(`Enrich failed: ${start?.error || "no job id"}`, "err");
    setTimeout(() => widget.close(), 5000);
    return;
  }
  // Reattach widget to the real job id so the cancel button works.
  widget.update(start);
  // The pipeline can be busy mid-LinkedIn-cycle (60-90s with rate-limit
  // spacing) and the Chrome MV3 service worker may sleep between polls,
  // making any single poll fail with "signal timed out" even though the
  // job is fine. Surface only after THREE consecutive misses, and back
  // off the poll interval each time.
  let lastSnapshot = start;
  let missCount = 0;
  const baseInterval = 5_000;
  const maxInterval = 30_000;

  // Live CRM refresh state. We refresh the list (so the operator sees
  // status dots update in real time) whenever the server reports new
  // employees_found / channels_added / tasks_completed — but throttled
  // so a busy job doesn't reload the list every 5s. 15s throttle is
  // long enough to avoid flicker, short enough to feel live.
  let lastRefreshAt = 0;
  let lastSignal = 0;
  const LIVE_REFRESH_MS = 15_000;

  const poll = async () => {
    if (!widget.isOpen()) return;
    const r = await api("crm-enrich-bulk-get", { jobId: start.id }).catch(() => null);
    if (!r || r.error) {
      missCount++;
      // First two misses are silent — likely SW eviction or a busy nexus-bot
      // cycle. The widget keeps showing the last known progress.
      if (missCount >= 3) {
        widget.update({
          ...lastSnapshot,
          _error: `${r?.error || "polling stalled"} (retry ${missCount})`,
        });
      }
      const backoff = Math.min(maxInterval, baseInterval * Math.pow(1.5, Math.min(missCount, 5)));
      setTimeout(poll, backoff);
      return;
    }
    missCount = 0;
    lastSnapshot = r;
    widget.update(r);
    if (r.status === "running") {
      // Live tick: when the server reports new data, refresh the CRM
      // list so the row's status dots reflect the freshly-found channels
      // (twitter, website, linkedin, etc.) without making the operator
      // hit refresh. Throttled to avoid the table flickering every 5s.
      const signal = (r.tasks_completed || 0) + (r.channels_added || 0) + (r.employees_found || 0);
      const now = Date.now();
      if (signal > lastSignal && now - lastRefreshAt > LIVE_REFRESH_MS) {
        lastSignal = signal;
        lastRefreshAt = now;
        // Preserve scroll position across the refresh so the operator
        // doesn't get yanked back to the top of the list every 15s.
        // loadCrm rebuilds #crm-list-wrap; the actual scroll container
        // could be the window OR the #content div depending on layout
        // — capture both and restore both.
        const winScroll = window.scrollY || document.documentElement.scrollTop || 0;
        const contentEl = document.getElementById("content");
        const contentScroll = contentEl?.scrollTop || 0;
        if (typeof loadCrm === "function") {
          loadCrm()
            .then(() => {
              // Restore after the new table is rendered. Use rAF so the
              // browser has done layout before we ask it to scroll.
              requestAnimationFrame(() => {
                if (winScroll > 0) window.scrollTo({ top: winScroll });
                if (contentScroll > 0 && contentEl) contentEl.scrollTop = contentScroll;
              });
            })
            .catch(() => {
              /* loadCrm errors get their own toast */
            });
        }
      }
      setTimeout(poll, baseInterval);
    } else {
      // Job done — final refresh always fires (regardless of throttle) so
      // the last few writes show. Then mark widget complete; the persistent
      // "See results" panel stays until the operator dismisses.
      if (typeof loadCrm === "function") loadCrm();
      widget.markComplete(r, ids, sel.type);
    }
  };
  setTimeout(poll, 1500);
}

function _openBulkEnrichWidget(initial, entityType) {
  const existing = document.getElementById("bulk-enrich-widget");
  if (existing) existing.remove();

  let isOpen = true;
  // Mutable job id — when the widget opens with a placeholder ("_starting"),
  // the real id arrives via update() once the API responds. The cancel
  // handler reads `currentJobId` at click time, not at closure time.
  let currentJobId = initial?.id || null;
  const card = el("div", {
    id: "bulk-enrich-widget",
    style: `position:fixed;right:20px;bottom:80px;width:360px;background:var(--bg-1);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:var(--radius-md);box-shadow:0 12px 32px rgba(0,0,0,0.4);z-index:250;padding:12px 14px;font-size:12px;animation:slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);`,
  });

  card.appendChild(
    el("div", { style: "display:flex;align-items:center;gap:8px;margin-bottom:8px;" }, [
      el("span", { style: "font-size:14px;" }, icon("bolt")),
      el(
        "div",
        { style: "flex:1;font-weight:600;color:var(--text-1);" },
        `Bulk enrich · ${entityType}`,
      ),
      el(
        "button",
        {
          class: "icon-btn",
          title: "Cancel & close",
          onclick: async () => {
            // Only attempt cancel if we actually have a real job id (not the
            // "_starting" placeholder). Closing during the placeholder phase
            // just dismisses the widget — the API call still runs server-side
            // but the operator sees nothing further.
            if (currentJobId && currentJobId !== "_starting") {
              try {
                await api("crm-enrich-bulk-cancel", { jobId: currentJobId });
              } catch {}
            }
            isOpen = false;
            card.remove();
          },
        },
        "×",
      ),
    ]),
  );

  const progressLine = el(
    "div",
    { class: "muted small mono", style: "margin-bottom:6px;" },
    "Queueing tasks…",
  );
  card.appendChild(progressLine);

  // Task-level breakdown row: ▶ running · ⏳ pending · ✓ done · ✕ failed
  const taskBreakdown = el(
    "div",
    { class: "muted small mono", style: "margin-bottom:6px;font-size:11px;" },
    "",
  );
  card.appendChild(taskBreakdown);

  const barWrap = el("div", {
    style: "height:4px;background:var(--bg-3);border-radius:2px;overflow:hidden;margin-bottom:8px;",
  });
  const barFill = el("div", {
    style: "height:100%;background:var(--accent);width:0%;transition:width 200ms linear;",
  });
  barWrap.appendChild(barFill);
  card.appendChild(barWrap);

  const counters = el("div", {
    style: "display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px;",
  });
  card.appendChild(counters);

  // Errors section — surfaces the most recent task-level errors so the user
  // can see WHY tasks are stuck (e.g. "no website found", "no LinkedIn match").
  // Hidden when empty so the widget stays tight.
  const errorsBox = el("div", {
    style:
      "display:none;max-height:120px;overflow-y:auto;padding:6px 8px;background:var(--bg-2);border-radius:4px;font-size:10px;line-height:1.5;",
  });
  card.appendChild(errorsBox);

  document.body.appendChild(card);

  function update(snapshot) {
    if (!isOpen) return;
    // Track the real job id once it arrives. Initial placeholder is
    // "_starting"; the first poll-able snapshot brings the actual id.
    if (snapshot && snapshot.id && snapshot.id !== "_starting") {
      currentJobId = snapshot.id;
    }
    const totalEntities = snapshot.total || 0;
    const processedEntities = snapshot.processed || 0;

    // Task-level progress is what the operator wants to SEE moving. The
    // entity-level rollup ("processed/total") only ticks once all 8-ish
    // tasks for a single company finish, so for a 4-company × 8-task job
    // the bar would sit at 0/4 for ~30s while individual tasks fly. Drive
    // the bar from tasks instead, and surface entities as a secondary
    // "milestone" line.
    const tCompleted = snapshot.tasks_completed || 0;
    const tFailed = snapshot.tasks_failed || 0;
    const tRunning = snapshot.tasks_running || 0;
    const tPending = snapshot.tasks_pending || 0;
    const tDeferred = snapshot.tasks_deferred || 0;
    // Total includes deferred — they're real tasks that exist in the queue.
    // "Done" includes deferred too: from this bulk session's perspective
    // they're handed off to the regular pipeline cycle (won't complete
    // here, but they're not blocking us either).
    const taskTotal = tCompleted + tFailed + tRunning + tPending + tDeferred;
    const taskDone = tCompleted + tFailed + tDeferred;
    const pct =
      taskTotal > 0
        ? Math.round((taskDone / taskTotal) * 100)
        : totalEntities > 0
          ? Math.round((processedEntities / totalEntities) * 100)
          : 0;
    barFill.style.width = `${pct}%`;

    if (snapshot._error) {
      progressLine.textContent = `${taskDone}/${taskTotal || totalEntities} · ${snapshot._error}`;
      progressLine.style.color = "var(--yellow)";
    } else if (snapshot.status === "running") {
      if (taskTotal === 0) {
        // Pre-first-poll: runner hasn't populated task tallies yet. Show
        // intent-to-act, not a misleading 0/0.
        progressLine.textContent = `Starting · queueing tasks for ${totalEntities} ${snapshot.type || "entities"}…`;
      } else {
        const verb = tRunning > 0 ? `running ${tRunning}` : tPending > 0 ? "queued" : "";
        progressLine.textContent = `${taskDone}/${taskTotal} tasks · ${pct}%${verb ? " · " + verb : ""}`;
      }
      progressLine.style.color = "";
    } else if (snapshot.status === "cancelled") {
      progressLine.textContent = `${taskDone}/${taskTotal} · cancelled`;
      progressLine.style.color = "var(--yellow)";
      barFill.style.background = "var(--yellow)";
    } else {
      progressLine.textContent = `${taskDone}/${taskTotal} · done`;
      progressLine.style.color = "var(--green)";
      barFill.style.background = "var(--green)";
    }

    // Granular per-task line + entity milestone. Three layers:
    //  - "Tasks: ▶ N · ⏳ N · ✓ N · ✕ N · ⏰ N deferred"  (changes constantly)
    //  - "X of Y companies finished"                        (entity milestone)
    // ⏰ deferred = tasks on a 5-45 min cooldown that won't drain in this
    // session but will retry on the regular pipeline cycle. Shown only
    // when non-zero so the line stays clean for clean runs.
    if (taskTotal > 0) {
      const deferredPart = tDeferred > 0 ? ` · ⏰ ${tDeferred} later` : "";
      const milestone =
        totalEntities > 0
          ? `   ·   ${processedEntities}/${totalEntities} ${snapshot.type || "entities"} done`
          : "";
      taskBreakdown.textContent = `Tasks: ▶ ${tRunning} · ⏳ ${tPending} · ✓ ${tCompleted} · ✕ ${tFailed}${deferredPart}${milestone}`;
    } else {
      taskBreakdown.textContent = "";
    }

    counters.innerHTML = "";
    counters.appendChild(_counterCell("👥", snapshot.employees_found || 0, "var(--accent)"));
    counters.appendChild(_counterCell("🔗", snapshot.channels_added || 0, "var(--green)"));
    counters.appendChild(_counterCell("⏳", snapshot.deferred || 0, "var(--yellow)"));
    counters.appendChild(_counterCell("⚠", snapshot.failed || 0, "var(--text-3)"));

    // Recent errors — group by (task_type + error message) so the operator
    // doesn't see "5x No website / 5x AI: unknown" as 10 separate rows.
    // Strip the "(retry N)" suffix before grouping so 1st/2nd/3rd attempts
    // of the same failure collapse together. Each grouped row shows a
    // count badge if it occurred >1 time.
    const errs = snapshot.recent_errors || [];
    if (errs.length > 0) {
      errorsBox.style.display = "block";
      errorsBox.innerHTML = "";
      const groups = new Map();
      for (const e of errs) {
        const cleanError = String(e.error || "")
          .replace(/\s*\(retry \d+\)\s*$/, "")
          .trim();
        const key = `${e.task_type}::${cleanError}`;
        const existing = groups.get(key);
        if (existing) existing.count++;
        else groups.set(key, { task_type: e.task_type, error: cleanError, count: 1 });
      }
      for (const g of groups.values()) {
        const row = el("div", {
          style:
            "color:var(--yellow);margin-bottom:3px;display:flex;gap:6px;align-items:flex-start;",
        });
        row.innerHTML =
          (g.count > 1
            ? `<span style="background:rgba(251,191,36,0.2);border-radius:8px;padding:1px 6px;font-size:10px;color:var(--yellow);font-weight:700;flex-shrink:0;">×${g.count}</span>`
            : "") +
          `<div><span style="opacity:0.7;">${escapeHtml(g.task_type)}</span> · ${escapeHtml(g.error)}</div>`;
        errorsBox.appendChild(row);
      }
    } else {
      errorsBox.style.display = "none";
    }
  }

  update(initial);

  // Transform the widget into "job complete" state. Stays visible until
  // the operator clicks ✕ or "See results" (which opens a detail drawer
  // and dismisses the widget). Non-intrusive: bottom-right, doesn't grab
  // focus, doesn't auto-close.
  function markComplete(snapshot, jobIds, jobType) {
    if (!isOpen) return;
    update(snapshot); // one final refresh so counters show the totals
    // Header tweak — flip the title from "Bulk enrich · companies" to
    // "Enrich complete · companies" + green left-border accent.
    card.style.borderLeftColor = "var(--green)";
    const title = card.querySelector("div[style*='flex:1'][style*='font-weight']");
    if (title) {
      title.textContent = `Enrich complete · ${jobType}`;
      title.style.color = "var(--green)";
    }
    // Replace the progress line with a one-line summary so the operator
    // can read the win at a glance without parsing four counters.
    const summaryParts = [];
    if (snapshot.employees_found) summaryParts.push(`+${snapshot.employees_found} employees`);
    if (snapshot.channels_added) summaryParts.push(`+${snapshot.channels_added} channels`);
    if (snapshot.failed) summaryParts.push(`${snapshot.failed} failed`);
    progressLine.textContent = summaryParts.join(" · ") || "No changes";
    progressLine.style.color = "var(--green)";

    // Inject a "See results →" action row. Idempotent — re-calling
    // markComplete (e.g. after a late status flip) doesn't duplicate it.
    const existingActions = card.querySelector(".enrich-actions");
    if (!existingActions) {
      const actions = el(
        "div",
        {
          class: "enrich-actions",
          style: "margin-top:10px;display:flex;gap:8px;align-items:center;",
        },
        [
          el(
            "button",
            {
              class: "btn btn-tiny btn-primary",
              type: "button",
              style: "flex:1;",
              onclick: () => {
                // Open the results drawer with the affected entity ids.
                // Closing the widget here keeps the bottom-right corner free.
                isOpen = false;
                card.remove();
                _openEnrichResultsDrawer({ jobIds, jobType, snapshot });
              },
            },
            "See results →",
          ),
          el(
            "button",
            {
              class: "btn btn-tiny btn-secondary",
              type: "button",
              title: "Dismiss",
              onclick: () => {
                isOpen = false;
                card.remove();
              },
            },
            "Dismiss",
          ),
        ],
      );
      card.appendChild(actions);
    }
  }

  return {
    update,
    markComplete,
    isOpen: () => isOpen,
    close: () => {
      isOpen = false;
      card.remove();
    },
  };
}

// Right-side drawer that shows what each entity in the bulk-enrich job
// looks like NOW (after the run). Pulls each row fresh via the existing
// CRM list endpoint with a high limit, locally filters to the affected
// ids, and shows the same status dots the CRM list uses — so the operator
// sees the latest channel/AI/confidence state in one place.
async function _openEnrichResultsDrawer({ jobIds, jobType, snapshot }) {
  const ids = Array.isArray(jobIds) ? jobIds : [...(jobIds || [])];
  if (ids.length === 0) {
    toast("No entities in this job", "info", 3000);
    return;
  }

  const existing = document.getElementById("enrich-results-drawer");
  if (existing) existing.remove();
  const oldBackdrop = document.getElementById("enrich-results-backdrop");
  if (oldBackdrop) oldBackdrop.remove();

  const backdrop = el("div", {
    id: "enrich-results-backdrop",
    style: "position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:300;",
    onclick: () => closeAll(),
  });
  const drawer = el("div", {
    id: "enrich-results-drawer",
    style: `position:fixed;right:0;top:0;bottom:0;width:420px;background:var(--bg-1);
            border-left:1px solid var(--border);z-index:301;padding:16px 18px;overflow-y:auto;
            animation:slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
            box-shadow:-12px 0 32px rgba(0,0,0,0.45);`,
  });

  // Header
  drawer.appendChild(
    el("div", { style: "display:flex;align-items:center;gap:10px;margin-bottom:14px;" }, [
      el("span", { style: "font-size:18px;" }, icon("bolt")),
      el(
        "div",
        { style: "flex:1;font-weight:700;font-size:14px;color:var(--text-1);" },
        `Enrichment results · ${jobType}`,
      ),
      el("button", { class: "icon-btn", title: "Close", onclick: () => closeAll() }, "×"),
    ]),
  );

  // Headline summary
  const sum = snapshot || {};
  drawer.appendChild(
    el(
      "div",
      {
        style:
          "padding:10px 12px;background:var(--bg-2);border-radius:var(--radius-sm);margin-bottom:14px;line-height:1.6;font-size:12px;",
      },
      [
        el("div", { class: "muted small" }, `${ids.length} ${jobType} processed`),
        el(
          "div",
          { class: "mono", style: "color:var(--green);" },
          `+${sum.employees_found || 0} employees · +${sum.channels_added || 0} channels`,
        ),
        sum.failed
          ? el(
              "div",
              { class: "mono small", style: "color:var(--text-3);" },
              `${sum.failed} failed`,
            )
          : null,
      ],
    ),
  );

  // List container
  const listWrap = el("div", { id: "enrich-results-list" }, [
    el("div", { class: "muted small", style: "padding:12px;text-align:center;" }, "Loading…"),
  ]);
  drawer.appendChild(listWrap);

  document.body.appendChild(backdrop);
  document.body.appendChild(drawer);

  function closeAll() {
    backdrop.remove();
    drawer.remove();
  }

  // Fetch the affected rows. The CRM list endpoint doesn't take an `ids`
  // filter today, so we fetch with a high enough limit (200) and locally
  // filter — works fine for the bulk-enrich cap of 50 ids per job. If a
  // row isn't on the first page (e.g. the user already filtered the CRM
  // tightly), we fall back to a click-to-open link by id.
  try {
    const action = jobType === "companies" ? "crm-companies-list" : "crm-contacts-list";
    const resp = await api(action, { queryString: "?limit=200" }).catch(() => null);
    const rows = resp?.[jobType] || [];
    const byId = new Map(rows.map((r) => [r.id, r]));
    listWrap.innerHTML = "";
    let foundCount = 0;
    for (const id of ids) {
      const row = byId.get(id);
      if (row) {
        listWrap.appendChild(_enrichResultRow(row, jobType, closeAll));
        foundCount++;
      } else {
        listWrap.appendChild(
          el(
            "div",
            {
              style: "padding:8px 10px;border-bottom:1px solid var(--border);cursor:pointer;",
              onclick: () => {
                closeAll();
                if (jobType === "companies") openCompanyDrawerById(id);
                else showContactDrawer(id);
              },
            },
            [
              el("span", { class: "muted small mono" }, `#${id}`),
              el(
                "span",
                { class: "muted small", style: "margin-left:8px;" },
                "(not on current page — click to open)",
              ),
            ],
          ),
        );
      }
    }
    if (foundCount === 0 && ids.length > 0) {
      listWrap.appendChild(
        el(
          "div",
          { class: "muted small", style: "padding:12px;text-align:center;" },
          "No rows on the current CRM page — click any ID to open it directly.",
        ),
      );
    }
  } catch (err) {
    listWrap.innerHTML = "";
    listWrap.appendChild(
      el(
        "div",
        { class: "muted small", style: "padding:12px;color:var(--yellow);" },
        `Failed to load: ${err.message}`,
      ),
    );
  }
}

// Single result row in the drawer. Reuses the same status-dot helpers
// as the CRM table so the icons + uncertainty indicator match exactly
// what the operator sees in the list.
function _enrichResultRow(row, jobType, closeFn) {
  const name = jobType === "companies" ? row.name || "—" : row.display_name || "—";
  // The status helpers return a <td>; we want a flex container instead.
  // Adopt their children rather than re-implementing the dot logic.
  const statusCell =
    jobType === "companies" ? renderCompanyStatusCell(row) : renderContactStatusCell(row);
  const dotsContainer = el("div", { style: "display:flex;align-items:center;flex-shrink:0;" });
  while (statusCell.firstChild) dotsContainer.appendChild(statusCell.firstChild);

  return el(
    "div",
    {
      style: `display:flex;align-items:center;gap:10px;padding:10px 6px;
            border-bottom:1px solid var(--border);cursor:pointer;
            transition:background 100ms ease;`,
      onmouseenter: (e) => {
        e.currentTarget.style.background = "var(--bg-2)";
      },
      onmouseleave: (e) => {
        e.currentTarget.style.background = "";
      },
      onclick: () => {
        closeFn();
        if (jobType === "companies") showCompanyDrawer(row);
        else showContactDrawer(row.id);
      },
    },
    [
      el("div", { style: "flex:1;min-width:0;" }, [
        el("div", { class: "primary-cell", style: "font-size:13px;line-height:1.3;" }, name),
        jobType === "companies" && row.sector
          ? el("div", { class: "muted small", style: "font-size:11px;" }, row.sector)
          : jobType === "contacts" && row.company
            ? el("div", { class: "muted small", style: "font-size:11px;" }, row.company)
            : null,
      ]),
      dotsContainer,
    ],
  );
}
