// dashboard-group-discovery.js — "Group Finder": keyword -> crawl -> reviewed,
// scored queue of Telegram groups. Read-only end to end: nothing here ever
// joins a group. Promote only writes group_campaign_targets rows for the
// existing staggered joiner to pick up later.
//
// Classic script, shared global scope with the rest of the dashboard-*.js
// family (loaded by dashboard.html before dashboard.js so renderGroupFinder
// is defined when the route map is built). Follows the el()/api()/uiCard
// conventions of the other view modules — see dashboard-signals-view.js.
//
// Every helper below is prefixed `_gf` and kept self-contained: it only
// calls globals oxlint's chrome-extension override actually declares (el,
// api, apiMutate, mutateAction, openModal, toast, navigate, setContentNode)
// plus plain browser/ES builtins. Cross-file helpers that exist at runtime
// but aren't in that allowlist (e.g. dashboard.js's own _discoverHubNav,
// $, escapeHtml) are deliberately NOT called here — every other split
// dashboard-*.js file already carries a large baselined no-undef count from
// exactly that pattern, and a brand-new file starts at zero, so the same
// pattern here would be new (unbaselined) lint debt.

let _gfState = { runId: null, poll: null, candidates: [], selected: new Set() };

// Local copy of the "Discover" hub's pill tabs (dashboard.js's own
// _DISCOVER_HUB_TABS gains a matching "Group Finder" entry so the OTHER
// Discover-hub views — Signals, Competitors, VC, Service Match — can jump
// here too). Duplicated rather than referenced for the lint reason above.
const _GF_HUB_TABS = [
  { route: "signals", icon: "flame", label: "Signals" },
  { route: "competitors", icon: "search", label: "Competitors" },
  { route: "vc", icon: "🏛", label: "VC / Investors" },
  { route: "service-match", icon: "🧠", label: "Service Match" },
  { route: "groupfinder", icon: "search", label: "Group Finder" },
];

function _gfHubNav(activeRoute) {
  return el(
    "div",
    { class: "row", style: "gap:6px;flex-wrap:wrap;margin-bottom:14px;" },
    _GF_HUB_TABS.map((t) =>
      el(
        "button",
        {
          type: "button",
          class: `btn btn-tiny ${t.route === activeRoute ? "btn-primary" : "btn-secondary"}`,
          onclick: () => {
            if (t.route !== activeRoute) navigate(t.route);
          },
        },
        // A mark beside the label rather than a glyph inside the string. The
        // template printed t.icon as TEXT, so once the data held mark names it
        // would have rendered the literal word "users" on the tab.
        [icon.for(t.icon) ? icon(icon.for(t.icon)) : el("span", {}, t.icon), " " + t.label],
      ),
    ),
  );
}

// Advancing funnel counters (found -> resolved -> passed_filter -> scored) so
// a run that takes minutes reads as progress, not a stuck spinner.
function _gfFunnel(run) {
  const stage = (label, n) => el("span", { class: "pill" }, `${label} ${n ?? 0}`);
  return el("div", { class: "row", style: "gap:6px;flex-wrap:wrap;" }, [
    stage("found", run.found),
    stage("resolved", run.resolved),
    stage("passed", run.passed_filter),
    stage("scored", run.scored),
  ]);
}

// score_components is a JSON string of the weighted parts that produced
// final_score (size/crm always present; activity only when probe_status was
// "probed", relevance only when the AI actually returned a verdict). Render
// the parts beside the total — a 78 from CRM overlap means something
// different from a 78 from message volume — and render a genuinely unscored
// row as "not scored", never as a 0 it never earned.
function _gfScoreCell(row) {
  let components = {};
  try {
    components = JSON.parse(row.score_components || "{}");
  } catch {
    components = {};
  }
  const parts = Object.entries(components)
    .map(([k, v]) => `${k} ${Math.round(v)}`)
    .join(" · ");
  return el("div", {}, [
    el("strong", {}, row.final_score != null ? String(row.final_score) : "—"),
    el("div", { class: "muted", style: "font-size:11px;" }, parts || "not scored"),
  ]);
}

// crm_overlap is a count, crm_sample a JSON array of names — the
// differentiator no directory can tell you. Render both together.
function _gfCrmText(row) {
  const n = Number(row.crm_overlap) || 0;
  if (n === 0) return "—";
  let sample = [];
  try {
    sample = JSON.parse(row.crm_sample || "[]");
  } catch {
    sample = [];
  }
  const names = Array.isArray(sample) ? sample.filter(Boolean).slice(0, 2) : [];
  return names.length
    ? `${n} of your contacts, incl. ${names.join(", ")}`
    : `${n} of your contacts`;
}

async function _gfDecide(ev, candidateId, decision) {
  await mutateAction(
    ev,
    "discover-decision",
    { candidateId, body: { decision } },
    {
      failPrefix: "Could not save decision",
      after: () => _gfRefresh(),
    },
  );
}

function _gfRow(row) {
  // probe_status "unavailable" means the group was private/unreadable — the
  // activity numbers are unknown, not zero. A private group we could not
  // read is unknown, not dead.
  const unprobed = row.probe_status !== "probed";

  const checkbox = el("input", {
    type: "checkbox",
    checked: _gfState.selected.has(row.id) ? "checked" : null,
    onchange: (e) => {
      if (e.target.checked) _gfState.selected.add(row.id);
      else _gfState.selected.delete(row.id);
    },
  });

  const titleCell = el(
    "td",
    {},
    [
      el(
        "a",
        { href: `https://t.me/${row.chat_ref}`, target: "_blank", rel: "noreferrer" },
        row.title || row.chat_ref,
      ),
      row.is_private
        ? el("span", { class: "badge badge-gray", style: "margin-left:6px;" }, "private")
        : null,
      unprobed
        ? el(
            "span",
            {
              class: "badge badge-yellow",
              style: "margin-left:6px;",
              title: "No read access — scored on metadata only, activity unknown",
            },
            "probe unavailable",
          )
        : null,
      row.llm_reason
        ? el("div", { class: "muted", style: "font-size:11px;margin-top:2px;" }, row.llm_reason)
        : null,
    ].filter(Boolean),
  );

  return el("tr", {}, [
    el("td", {}, checkbox),
    titleCell,
    el("td", {}, row.member_count != null ? Number(row.member_count).toLocaleString() : "—"),
    el("td", {}, unprobed ? "—" : Number(row.msgs_per_day || 0).toFixed(1)),
    el("td", {}, unprobed ? "—" : Number(row.unique_senders_per_day || 0).toFixed(1)),
    el("td", {}, _gfCrmText(row)),
    el("td", {}, _gfScoreCell(row)),
    el("td", {}, `${row.source} · h${row.hop_depth}`),
    el("td", {}, [
      el(
        "button",
        {
          class: `btn btn-tiny ${row.decision === "approved" ? "btn-primary" : "btn-secondary"}`,
          onclick: (e) => _gfDecide(e, row.id, "approved"),
        },
        row.decision === "approved" ? "Approved" : "Approve",
      ),
      el(
        "button",
        {
          class: `btn btn-tiny ${row.decision === "rejected" ? "btn-primary" : "btn-secondary"}`,
          onclick: (e) => _gfDecide(e, row.id, "rejected"),
        },
        row.decision === "rejected" ? "Rejected" : "Reject",
      ),
    ]),
  ]);
}

// Promote writes group_campaign_targets rows for the existing staggered
// joiner — it never joins anything itself. Lets the operator pick a real
// campaign from their own list rather than typing an id blind.
async function _gfOpenPromoteModal(run) {
  const data = await api("group-campaigns-list");
  const campaigns = data?.campaigns || [];
  if (campaigns.length === 0) {
    toast("No group campaigns yet — click to create one", "err", 6000, () => navigate("group-ops"));
    return;
  }

  const selectedIds = [..._gfState.selected];
  const select = el(
    "select",
    { class: "filter-select", style: "width:100%;margin-top:8px;" },
    campaigns.map((c) => el("option", { value: String(c.id) }, c.name || `Campaign #${c.id}`)),
  );
  const body = el("div", {}, [
    el(
      "p",
      { class: "muted", style: "font-size:12px;" },
      selectedIds.length
        ? `Adds ${selectedIds.length} selected candidate(s) as pending targets for the staggered joiner. Nothing joins immediately.`
        : "No rows selected — this adds every approved candidate on this run as a pending target. Nothing joins immediately.",
    ),
    select,
  ]);

  const dlg = openModal({
    title: "Promote to campaign",
    body,
    actions: [
      el(
        "button",
        { type: "button", class: "btn btn-secondary", onclick: () => dlg.close() },
        "Cancel",
      ),
      el(
        "button",
        {
          type: "button",
          class: "btn btn-primary",
          onclick: async (e) => {
            const campaignId = Number(select.value);
            await mutateAction(
              e,
              "discover-promote",
              { runId: run.id, body: { campaignId, candidateIds: selectedIds } },
              {
                failPrefix: "Could not promote",
                after: async (r) => {
                  // Promotion is idempotent per (campaign, group): say what
                  // was actually added rather than counting rows the server
                  // skipped because they were already on the campaign.
                  toast(
                    r.skipped
                      ? `Added ${r.inserted} target(s); ${r.skipped} already on this campaign.`
                      : `Added ${r.inserted} target(s) to the campaign.`,
                    "ok",
                  );
                  _gfState.selected.clear();
                  dlg.close();
                  await _gfRefresh();
                },
              },
            );
          },
        },
        "Promote →",
      ),
    ],
  });
}

// Re-enters the crawl for a run that parked every account on a rate limit.
// _gfRefresh afterwards flips the run out of the terminal set and restarts
// polling, so the funnel counters start moving again in place.
async function _gfResume(ev, run) {
  await mutateAction(
    ev,
    "discover-resume",
    { runId: run.id },
    {
      failPrefix: "Could not resume",
      after: async () => {
        toast("Resumed — picking up the queued groups where it stopped.", "ok");
        await _gfRefresh();
      },
    },
  );
}

function _gfErrorState(message) {
  return el("div", { class: "empty-state" }, [
    el("div", { class: "empty-state-icon" }, icon("alert")),
    el("div", { class: "empty-state-title" }, "Can't load this run"),
    el(
      "div",
      { class: "empty-state-hint" },
      message || "It may have been cancelled, or belongs to a different session.",
    ),
  ]);
}

function _gfResults(run, rows) {
  const header = el(
    "div",
    {
      class: "row",
      style: "justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;",
    },
    [
      el("div", {}, [
        el(
          "div",
          { style: "font-weight:600;margin-bottom:6px;" },
          `Run #${run.id} — ${run.status}`,
        ),
        _gfFunnel(run),
      ]),
      el(
        "button",
        { class: "btn btn-primary", onclick: () => _gfOpenPromoteModal(run) },
        "Promote selected →",
      ),
    ],
  );

  const body = [header];

  // paused_flood is expected and resumable (every account on this owner hit a
  // rate limit) — an explanatory note, not an error banner. Nothing resumes it
  // on its own, though: the copy must not promise an automatic recovery that
  // no scheduler performs. The frontier and the candidates found so far are
  // persisted, so the Resume button below genuinely continues this same run.
  if (run.status === "paused_flood") {
    body.push(
      el("div", { class: "row", style: "gap:8px;align-items:center;margin-top:8px;" }, [
        el(
          "div",
          { class: "badge badge-yellow" },
          "Paused — every account is rate-limited. Nothing is lost: the groups found so far are kept and the rest of the queue is saved. Resume once the limits clear.",
        ),
        el(
          "button",
          {
            class: "btn btn-tiny btn-secondary",
            onclick: (e) => _gfResume(e, run),
          },
          "Resume run",
        ),
      ]),
    );
  }
  if (run.error) {
    // A run can finish "done" and still carry a reason it under-delivered —
    // e.g. some of the owner's accounts could not connect, so part of the
    // frontier was never resolved. Gating this on status === "failed" hid that
    // note entirely, presenting a degraded run as a clean one.
    const failed = run.status === "failed";
    body.push(
      el(
        "div",
        {
          class: failed ? "badge badge-red" : "badge badge-yellow",
          style: "margin-top:8px;",
        },
        failed ? `Failed: ${run.error}` : run.error,
      ),
    );
  }

  if (rows.length === 0) {
    body.push(
      el(
        "div",
        { class: "muted", style: "padding:12px 0;" },
        run.status === "running" || run.status === "queued"
          ? "Crawling… results appear here as candidates qualify."
          : "No candidates survived the filters.",
      ),
    );
    return el("div", {}, body);
  }

  const table = el("table", { class: "table" }, [
    el(
      "thead",
      {},
      el(
        "tr",
        {},
        [
          "",
          "Group",
          "Members",
          "Msgs/day",
          "Senders/day",
          "CRM overlap",
          "Score",
          "Source",
          "",
        ].map((h) => el("th", {}, h)),
      ),
    ),
    el("tbody", {}, rows.map(_gfRow)),
  ]);
  body.push(el("div", { style: "overflow-x:auto;margin-top:10px;" }, table));
  return el("div", {}, body);
}

function _gfStopPolling() {
  if (_gfState.poll) {
    clearInterval(_gfState.poll);
    _gfState.poll = null;
  }
}

// A crawl runs for minutes to tens of minutes. Poll so the funnel counters
// actually advance instead of leaving the operator staring at a spinner, and
// self-cancel the moment the hash moves off groupfinder so navigating away
// doesn't leave a timer polling a #gf-results node that no longer exists.
function _gfEnsurePolling() {
  if (_gfState.poll) return;
  _gfState.poll = setInterval(() => {
    const onRoute = location.hash.replace(/^#/, "").split("/")[0] === "groupfinder";
    if (!onRoute) {
      _gfStopPolling();
      return;
    }
    _gfRefresh();
  }, 4000);
}

async function _gfRefresh() {
  if (!_gfState.runId) return;
  const host = document.getElementById("gf-results");
  if (!host) return;

  let status, list;
  try {
    [status, list] = await Promise.all([
      api("discover-status", { runId: _gfState.runId }),
      api("discover-candidates", { runId: _gfState.runId }),
    ]);
  } catch (err) {
    toast(`Group Finder: ${err?.message || "network error"}`, "err");
    return;
  }

  // GET .../:id 404s identically for "no such run" and "not your run" (so
  // existence isn't leaked) — either way that is NOT the same fact as "zero
  // candidates so far", and must never render as an empty results table.
  if (status?.error || !status?.run) {
    _gfStopPolling();
    host.replaceChildren(_gfErrorState(status?.error));
    return;
  }

  _gfState.candidates = list?.candidates || [];
  host.replaceChildren(_gfResults(status.run, _gfState.candidates));

  const terminal = ["done", "failed", "cancelled", "paused_flood"].includes(status.run.status);
  if (terminal) _gfStopPolling();
  else _gfEnsurePolling();
}

async function renderGroupFinder() {
  const root = el("div", {});
  setContentNode(root);

  root.appendChild(_gfHubNav("groupfinder"));
  root.appendChild(
    el("div", { class: "page-header" }, [
      el("div", {}, [
        el("div", { class: "page-title" }, "Group Finder"),
        el(
          "div",
          { class: "page-subtitle" },
          "Keyword → crawl → scored, reviewable Telegram groups. Read-only — nothing is ever joined here.",
        ),
      ]),
    ]),
  );

  const keywordInput = el("input", { class: "filter-input", placeholder: 'e.g. "ibiza crypto"' });
  const objectiveInput = el("input", {
    class: "filter-input",
    placeholder: "What are you looking for? (optional)",
  });
  const depthSelect = el(
    "select",
    { class: "filter-select" },
    [0, 1, 2, 3].map((d) =>
      el(
        "option",
        { value: String(d), selected: d === 2 ? "selected" : null },
        `${d} hop${d === 1 ? "" : "s"}`,
      ),
    ),
  );

  const startBtn = el(
    "button",
    {
      class: "btn btn-primary",
      onclick: async () => {
        const keyword = keywordInput.value.trim();
        if (!keyword) {
          toast("Enter a keyword", "err");
          return;
        }
        startBtn.disabled = true;
        try {
          const r = await apiMutate("discover-start", {
            body: {
              keyword,
              objective: objectiveInput.value.trim(),
              maxDepth: Number(depthSelect.value),
            },
          });
          if (r?.error) {
            toast(`Could not start: ${r.error}`, "err");
            return;
          }
          _gfStopPolling();
          _gfState = { runId: r.runId, poll: null, candidates: [], selected: new Set() };
          await _gfRefresh();
        } catch (err) {
          toast(`Could not start: ${err?.message || "network error"}`, "err");
        } finally {
          startBtn.disabled = false;
        }
      },
    },
    "Find groups",
  );

  root.appendChild(
    el("div", { class: "card", style: "margin-bottom:16px;" }, [
      el("div", { class: "card-title" }, "Find Telegram groups by keyword"),
      el("div", { class: "row", style: "gap:8px;flex-wrap:wrap;" }, [
        keywordInput,
        objectiveInput,
        depthSelect,
        startBtn,
      ]),
    ]),
  );

  const resultsHost = el("div", { id: "gf-results" });
  root.appendChild(resultsHost);

  if (_gfState.runId) {
    // Revisiting mid-run: one refresh restores the last-known state, and
    // _gfRefresh restarts polling itself if the run is still in flight.
    await _gfRefresh();
  } else {
    resultsHost.appendChild(
      el(
        "div",
        { class: "muted", style: "padding:12px 0;" },
        "Enter a keyword above and run a search.",
      ),
    );
  }
}
