import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

/**
 * Behavioural tests for the two client views (roster + command center).
 *
 * These files are classic scripts in the dashboard's shared global scope, and
 * there is no DOM in CI (browser downloads are disabled — see
 * PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD in ci.yml). Rather than settle for
 * source-pattern assertions, this loads each script into a Function scope with
 * a MINIMAL DOM/helper stub and actually calls the renderers.
 *
 * That distinction matters: every gate in this repo was green while
 * renderClientsRoster threw "Cannot set properties of null" on the first line
 * of its mount, because nothing here had ever executed it. Each test below
 * targets a bug that really shipped, and each was mutation-checked by
 * reintroducing that bug.
 */

const here = dirname(fileURLToPath(import.meta.url));

/** A node just real enough for these renderers. */
function makeEl(tag, attrs, children) {
  const node = {
    tag,
    attrs: attrs || {},
    style: {},
    dataset: {},
    children: [],
    _text: "",
    appendChild(c) {
      if (c) this.children.push(c);
      return c;
    },
    insertBefore(c) {
      if (c) this.children.push(c);
      return c;
    },
    addEventListener() {},
    querySelectorAll() {
      return [];
    },
    closest() {
      return null;
    },
  };
  const add = (c) => {
    if (c == null || c === false) return;
    if (Array.isArray(c)) return c.forEach(add);
    if (typeof c === "object") node.children.push(c);
    else node._text += String(c);
  };
  add(children);
  if (attrs && attrs.value != null) node.value = attrs.value;
  return node;
}

/** Flatten a stub tree to text, for assertions about what the user sees. */
function textOf(node) {
  if (node == null || typeof node !== "object") return String(node ?? "");
  return (node._text || "") + (node.children || []).map(textOf).join(" ");
}

/** Walk a stub tree collecting every node (for style/colour assertions). */
function flatten(node, out = []) {
  if (node && typeof node === "object") {
    out.push(node);
    (node.children || []).forEach((c) => flatten(c, out));
  }
  return out;
}

/**
 * Evaluate a dashboard view script with injected globals and hand back the
 * functions it declared. Function declarations inside a Function body are
 * scoped to it, so this cannot leak between tests.
 */
function loadView(file, stubs, exportNames) {
  const src = readFileSync(join(here, file), "utf8");
  const names = Object.keys(stubs);
  const body = `${src}\n;return {${exportNames.join(",")}};`;
  return new Function(...names, body)(...Object.values(stubs));
}

function baseStubs(overrides = {}) {
  const calls = { api: [], toast: [], mounted: [], navigate: [], modal: [] };
  const stubs = {
    el: makeEl,
    document: { getElementById: () => null },
    window: { confirm: () => false },
    navigator: { platform: "test" },
    api: async (route, params) => {
      calls.api.push({ route, params });
      const handler = overrides.apiResponses && overrides.apiResponses[route];
      if (typeof handler === "function") return handler(params);
      return handler !== undefined ? handler : {};
    },
    toast: (msg, kind) => calls.toast.push({ msg: String(msg), kind }),
    showLoading: () => {},
    showError: (e) => calls.toast.push({ msg: "ERROR:" + e, kind: "err" }),
    setContentNode: (node) => calls.mounted.push(node),
    navigate: (r) => calls.navigate.push(r),
    openModal: (cfg) => calls.modal.push(cfg),
    mutateAction: () => {},
    statCard: (label, value, sub) => makeEl("div", {}, [String(label), String(value), String(sub)]),
    // Real global from dashboard-icons.js, which both shells load before this
    // view (dashboard.html:147 vs :204, app.html:264 vs :321). Missing here, the
    // empty-state branch threw `icon is not defined` — a harness gap, not a
    // product bug, but it hid whether that branch renders at all.
    icon: (name) => makeEl("span", { class: `icon-${name}` }),
    makeSortableHeader: () => makeEl("thead", {}),
    sortRows: (rows) => rows,
    setNavCapability: () => {},
    renderClientReport: () => {},
    dashboardState: { sort: {}, currentParams: {} },
    chrome: { storage: { local: { set: () => {}, get: async () => ({}) } } },
  };
  return { stubs, calls };
}

// ── Roster ──────────────────────────────────────────────────────────

const ROSTER_EXPORTS = ["renderClientsRoster", "_crNextAction", "_crAgo"];

test("roster mounts through setContentNode (the #main crash regression)", async () => {
  const { stubs, calls } = baseStubs({
    apiResponses: {
      "clients-overview": {
        clients: [
          {
            id: "cl_1",
            name: "Acme",
            state: "active",
            activeAutomations: 3,
            pipelineRows: 0,
            accountCount: 1,
            lastActionAt: "2026-08-12 10:00:00",
          },
        ],
      },
    },
  });
  const view = loadView("dashboard-clients-roster.js", stubs, ROSTER_EXPORTS);
  await view.renderClientsRoster();

  // The original bug: the view built into document.getElementById("main"),
  // which is null, so it threw before rendering anything. The stub returns null
  // for every id, so any reintroduction throws here rather than in production.
  assert.equal(calls.mounted.length, 1, "the roster must mount exactly once via setContentNode");
  const rendered = textOf(calls.mounted[0]);
  assert.match(rendered, /Clients/);
  assert.match(rendered, /Acme/, "the client row must actually render");
  assert.equal(
    calls.api[0].route,
    "clients-overview",
    "the roster reads the overview endpoint, not the legacy clients list",
  );
});

test("roster renders an empty state — and still mounts it", async () => {
  const { stubs, calls } = baseStubs({ apiResponses: { "clients-overview": { clients: [] } } });
  const view = loadView("dashboard-clients-roster.js", stubs, ROSTER_EXPORTS);
  await view.renderClientsRoster();
  assert.equal(calls.mounted.length, 1, "the empty state must mount too, not silently return");
  assert.match(textOf(calls.mounted[0]), /No clients yet/);
});

test("roster surfaces a failing endpoint instead of rendering an empty table", async () => {
  const { stubs, calls } = baseStubs({
    apiResponses: { "clients-overview": { error: "unreachable" } },
  });
  const view = loadView("dashboard-clients-roster.js", stubs, ROSTER_EXPORTS);
  await view.renderClientsRoster();
  assert.equal(calls.mounted.length, 0, "nothing should mount on error");
  assert.match(calls.toast[0].msg, /unreachable/, "the failure must be shown, not swallowed");
});

test("idle-reason does not accuse a working client of having no accounts", () => {
  const { stubs } = baseStubs();
  const { _crNextAction } = loadView("dashboard-clients-roster.js", stubs, ROSTER_EXPORTS);

  // The real regression: the live client has 12 running automations and ZERO
  // account_clients rows (that table is optional labelling, not the metric
  // path). Leading with "no accounts assigned" was a confident wrong answer.
  const working = _crNextAction({
    activeAutomations: 12,
    accountCount: 0,
    lastActionAt: "2026-08-12 10:00:00",
  });
  assert.equal(working.tone, "ok");
  assert.match(working.text, /last action/);
  assert.doesNotMatch(working.text, /no accounts/i);

  // Only when nothing is running is a missing sender roster the useful answer.
  assert.match(_crNextAction({ activeAutomations: 0, accountCount: 0 }).text, /no accounts/i);
  assert.match(
    _crNextAction({ activeAutomations: 0, accountCount: 2, pipelineRows: 5 }).text,
    /5 pipeline rows, 0 automations/,
  );
  assert.equal(_crNextAction({ archived: true, activeAutomations: 0 }).text, "archived");
});

// ── Command center ──────────────────────────────────────────────────

const CC_EXPORTS = [
  "_ccRunBars",
  "_ccAddAutomationModal",
  "_ccRemoveModal",
  "_ccAgo",
  "_ccTargetsCard",
  "_ccStateFilterBar",
];

test("a skipped run renders grey and is excluded from the success tally", () => {
  const { stubs } = baseStubs();
  const { _ccRunBars } = loadView("dashboard-client-command-center.js", stubs, CC_EXPORTS);

  const node = _ccRunBars({ recentRuns: 5, ok: 2, failed: 0, skipped: 3 });
  const ticks = flatten(node).filter((n) => n.attrs && /background:/.test(n.attrs.style || ""));
  const grey = ticks.filter((n) => /#6b7280/.test(n.attrs.style)).length;
  const green = ticks.filter((n) => /--status-ok/.test(n.attrs.style)).length;

  // Before the fix, skips were painted with the SUCCESS colour: a budget-stopped
  // automation showed five green bars and read as working.
  assert.equal(grey, 3, "skips must be grey");
  assert.equal(green, 2, "only genuine successes may be green");
  assert.match(textOf(node), /2\/2 · 3 skipped/, "the tally must separate skips from runs");
});

test("run bars stay honest when the engine keeps no run log", () => {
  const { stubs } = baseStubs();
  const { _ccRunBars } = loadView("dashboard-client-command-center.js", stubs, CC_EXPORTS);
  assert.equal(
    textOf(_ccRunBars(null)).trim(),
    "—",
    "no run log must read as unknown, not as zero successes",
  );
  assert.match(textOf(_ccRunBars({ recentRuns: 0, ok: 0, failed: 0 })), /never run/);
});

test("add-automation reads the PRESET catalog and offers only clonable presets", async () => {
  // /api/campaigns/workflows ("workflows-list") returns campaign_workflows rows
  // and has no `kind`; the catalog is "automations-list" (/api/workflows), whose
  // list puts already-cloned customs FIRST. Both mistakes shipped: the wrong
  // route emptied the picker, and the missing filter defaulted it to a custom
  // that 404s on clone.
  const { stubs, calls } = baseStubs({
    apiResponses: {
      "automations-list": {
        workflows: [
          { id: "custom_68", kind: "custom", name: "Already cloned", schedule_min: 15 },
          { id: "signal_twitter", kind: "preset", name: "Signal scan", schedule_min: 15 },
          { id: "account_warmer", kind: "preset", name: "Account warmer" },
        ],
      },
      "workflows-list": { workflows: [{ id: 12, name: "ibiza" }] },
    },
  });
  const view = loadView("dashboard-client-command-center.js", stubs, CC_EXPORTS);
  await view._ccAddAutomationModal("cl_1");

  assert.deepEqual(
    calls.api.map((c) => c.route),
    ["automations-list"],
    "must read the preset catalog, never /api/campaigns/workflows",
  );
  assert.equal(calls.modal.length, 1, "the picker must open");
  const options = flatten(calls.modal[0].body).filter((n) => n.tag === "option");
  assert.equal(options.length, 2, "only kind:'preset' rows are clonable");
  assert.deepEqual(
    options.map((o) => o.attrs.value),
    ["signal_twitter", "account_warmer"],
    "an already-cloned custom must never be offered (cloning it 404s)",
  );
  assert.match(textOf(calls.modal[0].body), /Start paused/, "the opt-out must be visible");
});

test("untag maps read-model kinds to the tagging API's kinds", async () => {
  // campaigns-model emits workflow/email; WORK_TABLES expects
  // custom_workflow/email_campaign. Passing the raw prefix 400s "unknown_kind",
  // so untagging was impossible for those two kinds.
  const { stubs, calls } = baseStubs({ apiResponses: { "work-tag-client": { ok: true } } });
  const view = loadView("dashboard-client-command-center.js", stubs, CC_EXPORTS);

  for (const [id, expectKind] of [
    ["workflow:68", "custom_workflow"],
    ["email:5", "email_campaign"],
    ["composition:4874", "composition"],
    ["boost:3", "boost"],
    ["campaign_workflow:12", "campaign_workflow"],
  ]) {
    calls.api.length = 0;
    calls.modal.length = 0;
    view._ccRemoveModal({ id, name: id }, "cl_1");
    await calls.modal[0].onConfirm();
    const sent = calls.api.find((c) => c.route === "work-tag-client");
    assert.ok(sent, `${id}: must call the tagging endpoint`);
    assert.equal(sent.params.kind, expectKind, `${id} must untag as "${expectKind}"`);
    assert.equal(sent.params.body.client_id, null, "untag clears the client, it does not archive");
  }
});

test("the Remove modal keeps 'stop running' and 'stop attributing' distinct", () => {
  const { stubs, calls } = baseStubs();
  const view = loadView("dashboard-client-command-center.js", stubs, CC_EXPORTS);
  view._ccRemoveModal({ id: "composition:1", name: "Thing" }, "cl_1");
  const body = textOf(calls.modal[0].body);
  assert.match(body, /Stop it running/, "archiving must be described");
  assert.match(body, /Stop attributing it/, "untagging must be described");
  assert.match(
    body,
    /stops sending for everyone/i,
    "the blast radius of archiving must be stated — it is not client-scoped",
  );
});

test("the group targets card names the groups that rejected us", () => {
  const { stubs } = baseStubs();
  const { _ccTargetsCard } = loadView("dashboard-client-command-center.js", stubs, CC_EXPORTS);

  // The live shape: 4 targets, 2 of which returned CHAT_WRITE_FORBIDDEN. Count
  // the posts alone and this campaign looks healthy; half of it is dead.
  const card = _ccTargetsCard([
    { group_name: "Good Group", group_identifier: "@good", status: "joined", post_count: 1 },
    { group_name: "Rude Group", group_identifier: "@rude", status: "failed", post_count: 0 },
    { group_name: "Other Rude", group_identifier: "@rude2", status: "failed", post_count: 0 },
    { group_name: "Waiting", group_identifier: "@wait", status: "pending", post_count: 0 },
  ]);
  const txt = textOf(card);
  assert.match(txt, /Groups \(4\)/);
  assert.match(txt, /2 of 4 rejected the message/, "the dead half must be stated, not implied");
  assert.match(txt, /Rude Group/);
  // "failed" is engine vocabulary; the row has to say what it means for the user.
  assert.match(txt, /rejected us/);
  const red = flatten(card).filter((n) => /--status-bad/.test((n.attrs && n.attrs.style) || ""));
  assert.ok(red.length >= 2, "rejected targets must carry the failure colour");
});

test("targets card is omitted for kinds that have no targets", () => {
  const { stubs } = baseStubs();
  const { _ccTargetsCard } = loadView("dashboard-client-command-center.js", stubs, CC_EXPORTS);
  // A composition's detail carries no `targets`; rendering an empty "Groups (0)"
  // card would invent a concept that kind does not have.
  assert.equal(_ccTargetsCard(undefined), null);
  assert.equal(_ccTargetsCard([]), null);
});

test("drafts are hidden by default when they bury the working set", async () => {
  const { stubs } = baseStubs();
  const { _ccStateFilterBar } = loadView("dashboard-client-command-center.js", stubs, CC_EXPORTS);

  // OnchainBureau's real numbers: 64 tagged automations, 45 never-run drafts.
  const rows = [
    ...Array.from({ length: 4 }, () => ({ state: "live" })),
    ...Array.from({ length: 15 }, () => ({ state: "paused" })),
    ...Array.from({ length: 45 }, () => ({ state: "draft" })),
  ];
  const trs = rows.map((r) => ({ dataset: { state: r.state, aid: "x" }, style: {} }));
  const tbody = { querySelectorAll: () => trs };

  const bar = _ccStateFilterBar(rows, tbody);
  await new Promise((r) => setTimeout(r, 0)); // the filter applies on the next tick
  assert.equal(
    trs.filter((t) => t.style.display === "none").length,
    45,
    "every draft row must start hidden",
  );
  assert.equal(
    trs.filter((t) => t.dataset.state !== "draft").every((t) => t.style.display === ""),
    true,
    "live and paused rows must never be hidden",
  );
  const txt = textOf(bar);
  assert.match(txt, /4 live/);
  assert.match(txt, /15 paused/);
  assert.match(txt, /Show 45 drafts/);
});

test("a balanced client is shown whole — no auto-hiding", async () => {
  const { stubs } = baseStubs();
  const { _ccStateFilterBar } = loadView("dashboard-client-command-center.js", stubs, CC_EXPORTS);
  const rows = [{ state: "live" }, { state: "live" }, { state: "draft" }, { state: "draft" }];
  const trs = rows.map((r) => ({ dataset: { state: r.state }, style: {} }));
  const bar = _ccStateFilterBar(rows, { querySelectorAll: () => trs });
  await new Promise((r) => setTimeout(r, 0));
  assert.equal(
    trs.some((t) => t.style.display === "none"),
    false,
    "with few drafts, hiding them costs more than it saves",
  );
  assert.match(textOf(bar), /Hide drafts/, "the control is still offered, just not pre-applied");
});
