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

// Route-drift guard for the CLIENT-SIDE render dispatch (`routes`) vs the places
// that navigate into it: the V3 sidebar (V3_SECTIONS) and the ⌘K command palette.
//
// This catches "rename-drift": a route gets renamed in the `routes` dispatch but
// a nav child / palette entry / op-card keeps the OLD name, so clicking it
// dead-ends on the "Unknown route" empty-state. Two live instances of exactly
// this (`new-workflow`, `new-campaign-wizard`) shipped undetected until 2026-07;
// this test would have failed on both. It is the client-render analogue of the
// dashboard-routes.drift.test.js check (which only covers the API proxy map).
//
// NOTE: only STATIC double-quoted routes are checked. Parametric routes assembled
// with template literals (navigate(`campaigns/${id}`)) are resolved by parseHash
// at runtime and are intentionally out of scope here.

const here = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(join(here, "dashboard.js"), "utf8");
const lines = src.split(/\r?\n/);

// Slice a top-level `const NAME = {` / `[` ... balanced-close block by brace depth.
// Safe for these blocks: none of their string values contain stray braces/brackets.
function sliceBlock(startRe) {
  const s = lines.findIndex((l) => startRe.test(l));
  assert.ok(s >= 0, `block start ${startRe} found in dashboard.js`);
  let depth = 0;
  for (let i = s; i < lines.length; i++) {
    for (const ch of lines[i]) {
      if (ch === "{" || ch === "[") depth++;
      else if (ch === "}" || ch === "]") depth--;
    }
    if (i > s && depth === 0) return lines.slice(s, i + 1).join("\n");
  }
  throw new Error(`unterminated block for ${startRe}`);
}

function dispatchKeys() {
  const block = sliceBlock(/^const routes = \{/);
  const keys = new Set();
  for (const m of block.matchAll(/^\s*"?([a-zA-Z0-9_/-]+)"?\s*:/gm)) keys.add(m[1]);
  return keys;
}

test("every V3 sidebar route resolves to a render dispatch handler", () => {
  const keys = dispatchKeys();
  assert.ok(keys.size > 90, `expected the full routes dispatch, got ${keys.size}`);
  const v3 = sliceBlock(/^const V3_SECTIONS = \[/);
  const navRoutes = [...new Set([...v3.matchAll(/route:\s*"([^"]+)"/g)].map((m) => m[1]))];
  assert.ok(navRoutes.length > 20, `expected the full nav route set, got ${navRoutes.length}`);
  const dead = navRoutes.filter((r) => !keys.has(r));
  assert.deepEqual(
    dead,
    [],
    `V3_SECTIONS routes with no handler in the routes dispatch (would show "Unknown route"): ${dead.join(", ")}`,
  );
});

test("every command-palette route resolves to a render dispatch handler", () => {
  const keys = dispatchKeys();
  // The ⌘K command palette was extracted into its own module
  // (dashboard-k-command-palette.js) in the origin/main split. Its entries are
  // `{ route: "x", label: "…", keys: "…" }` (route IMMEDIATELY followed by label).
  // Every palette route must resolve to a handler in dashboard.js's dispatch, or
  // the action dead-ends on "Unknown route".
  const paletteSrc = readFileSync(join(here, "dashboard-k-command-palette.js"), "utf8");
  const palette = [
    ...new Set([...paletteSrc.matchAll(/route:\s*"([^"]+)",\s*label:/g)].map((m) => m[1])),
  ];
  assert.ok(palette.length > 30, `expected the command palette route set, got ${palette.length}`);
  const dead = palette.filter((r) => !keys.has(r));
  assert.deepEqual(
    dead,
    [],
    `command-palette routes with no handler in the routes dispatch (dead-end on "Unknown route"): ${dead.join(", ")}`,
  );
});

// ── The reverse direction ────────────────────────────────────────────────
//
// The three tests above all ask "does this nav entry reach a handler?". None
// asks the opposite: "can a user reach this handler?". A route that exists in
// the dispatch but appears in no sidebar, no palette and no other view is
// reachable only by typing its hash — so it rots unnoticed, and nothing here
// would ever say so.
//
// Most off-sidebar routes are legitimate: detail views opened by clicking a row
// (dashboard.js says so explicitly). Those are baselined below. What the gate is
// for is stopping the list from growing — a NEW orphan means someone built a
// view and never wired a way in.
//
// Baselined today: 40 routes, of which five have zero inbound navigation of any
// kind — x/hub, telegram/hub, linkedin/hub, email/hub (all four rendering the
// 738-line dashboard-platform-hub-phase-4.js) and run-previews. They are listed
// so the decision to keep or delete them is deliberate rather than forgotten.
const KNOWN_OFF_NAV = new Set(
  JSON.parse(readFileSync(join(here, "..", "tools", ".nav-orphans-baseline.json"), "utf8")).orphans,
);

test("no NEW route is unreachable from the sidebar, palette or a section map", () => {
  const keys = dispatchKeys();

  const reachable = new Set();
  const v3 = sliceBlock(/^const V3_SECTIONS = \[/);
  for (const m of v3.matchAll(/route:\s*"([^"]+)"/g)) reachable.add(m[1]);
  const footer = lines.some((l) => /^const V3_FOOTER = \[/.test(l))
    ? sliceBlock(/^const V3_FOOTER = \[/)
    : "";
  for (const m of footer.matchAll(/route:\s*"([^"]+)"/g)) reachable.add(m[1]);
  const paletteSrc = readFileSync(join(here, "dashboard-k-command-palette.js"), "utf8");
  for (const m of paletteSrc.matchAll(/route:\s*"([^"]+)",\s*label:/g)) reachable.add(m[1]);
  // The V1/V2 sidebars live in a DIFFERENT file and this test never read it, so
  // routes reachable only from them looked like orphans. That blind spot is how
  // the four <platform>/hub routes came to be deleted as "no callers".
  const sidebarSrc = readFileSync(join(here, "dashboard-dynamic-sidebar-builder.js"), "utf8");
  for (const m of sidebarSrc.matchAll(/route:\s*"([^"]+)"/g)) reachable.add(m[1]);
  for (const m of sidebarSrc.matchAll(/navigate\(\s*"([^"]+)"/g)) reachable.add(m[1]);
  // parseHash SYNTHESIZES route names that appear nowhere as a literal: e.g.
  // #telegram/overview resolves to "telegram/hub". A grep-based reachability
  // check cannot see these, so derive them from the same source of truth the
  // router uses — the HUB_SECTIONS block in dashboard.js.
  const hubBlock = src.match(
    /const HUB_SECTIONS = new Set\(\[([^\]]*)\]\);\s*if \(\[([^\]]*)\]\.includes\(parts\[0\]\)/,
  );
  if (hubBlock) {
    const platforms = [...hubBlock[2].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
    for (const p of platforms) reachable.add(`${p}/hub`);
  }
  // The section map names every route the sidebar highlights for, which covers
  // detail routes owned by a room even when they have no nav entry of their own.
  const map = sliceBlock(/^\s*const map = \{/);
  for (const m of map.matchAll(/"?([a-zA-Z0-9_/-]+)"?\s*:\s*"[a-z0-9-]+"/g)) reachable.add(m[1]);

  const orphans = [...keys].filter((k) => !reachable.has(k)).sort();
  const fresh = orphans.filter((o) => !KNOWN_OFF_NAV.has(o));

  assert.deepEqual(
    fresh,
    [],
    `${fresh.length} NEW route(s) in the dispatch that nothing navigates to — ` +
      `wire them into V3_SECTIONS/the palette, or delete the view:\n  ${fresh.join("\n  ")}`,
  );
});

// The orphan test above catches a route nothing navigates to. This catches the
// OPPOSITE and more damaging case: a hash the router can produce that has no
// handler. parseHash SYNTHESIZES "<platform>/hub" for #telegram/overview,
// #x/monitor etc. — names that appear in no nav table and no call site — so
// deleting them looked safe and silently 404'd every platform Overview/
// Operations entry point. Grep cannot see this; only the router's own table can.
test("every route parseHash can synthesize has a dispatch handler", () => {
  const keys = dispatchKeys();
  const hubBlock = src.match(
    /const HUB_SECTIONS = new Set\(\[([^\]]*)\]\);\s*if \(\[([^\]]*)\]\.includes\(parts\[0\]\)/,
  );
  assert.ok(hubBlock, "the HUB_SECTIONS synthesis block should still be in parseHash");
  const platforms = [...hubBlock[2].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
  assert.ok(platforms.length >= 4, `expected the platform list, got ${platforms.join(",")}`);
  const missing = platforms.map((p) => `${p}/hub`).filter((r) => !keys.has(r));
  assert.deepEqual(
    missing,
    [],
    `parseHash routes #<platform>/overview|operations|monitor to these, but they have no handler ` +
      `(every one of those hashes would show "Unknown route"): ${missing.join(", ")}`,
  );
});

// Two entries pointing at the same route means the same page has two homes in
// the menu, so the active-state highlights in one place while the user clicked
// the other. It is also the failure mode of a bad section collapse: fold six
// children into two and it is easy to leave a duplicate behind.
test("no two nav children resolve to the same route", () => {
  const v3 = sliceBlock(/^const V3_SECTIONS = \[/);
  // Children only: a section's own `route` legitimately repeats its first child
  // (Contacts → crm, Campaigns → campaigns) as the landing target for the room.
  // So collect routes from inside each `children: [...]` array, not the whole block.
  const all = [];
  for (const m of v3.matchAll(/children:\s*\[/g)) {
    let depth = 0;
    let i = m.index + m[0].length - 1;
    for (; i < v3.length; i++) {
      if (v3[i] === "[") depth++;
      else if (v3[i] === "]" && --depth === 0) break;
    }
    const body = v3.slice(m.index, i);
    for (const r of body.matchAll(/route:\s*"([^"]+)"/g)) all.push(r[1]);
  }
  assert.ok(all.length > 15, `expected the full child route set, got ${all.length}`);
  const seen = new Set();
  const dupes = [...new Set(all.filter((r) => (seen.has(r) ? true : (seen.add(r), false))))];
  assert.deepEqual(
    dupes,
    [],
    `route(s) appearing as more than one nav child — give each page exactly one home: ${dupes.join(", ")}`,
  );
});

// A section can be marked gated without a predicate, in which case it silently
// renders for everyone and the gate exists in name only.
test("every gated section declares a visibleWhen predicate", () => {
  const v3 = sliceBlock(/^const V3_SECTIONS = \[/);
  // Split into per-section chunks on the `key:` boundary so a predicate declared
  // in a DIFFERENT section can't satisfy this one.
  const chunks = v3.split(/\n\s*\{\s*\n?\s*key:/).slice(1);
  const offenders = [];
  for (const chunk of chunks) {
    const key = (chunk.match(/^\s*"?([a-z0-9_-]+)"?/i) || [])[1] || "?";
    if (/gated:\s*true/.test(chunk) && !/visibleWhen:\s*\(/.test(chunk)) offenders.push(key);
  }
  assert.deepEqual(
    offenders,
    [],
    `section(s) marked gated:true with no visibleWhen predicate (they render for everyone): ${offenders.join(", ")}`,
  );
});

test("every V3_ROUTE_TO_SECTION target is a real V3 section key", () => {
  const v3 = sliceBlock(/^const V3_SECTIONS = \[/);
  const sectionKeys = new Set([...v3.matchAll(/\{\s*key:\s*"([^"]+)"/g)].map((m) => m[1]));
  assert.ok(sectionKeys.size >= 5, `expected the room keys, got ${sectionKeys.size}`);
  // The map lives inside the V3_ROUTE_TO_SECTION IIFE as `const map = { … }`.
  const map = sliceBlock(/^\s*const map = \{/);
  const targets = [...new Set([...map.matchAll(/:\s*"([a-z0-9-]+)"/g)].map((m) => m[1]))];
  const bad = targets.filter((t) => !sectionKeys.has(t));
  assert.deepEqual(
    bad,
    [],
    `V3_ROUTE_TO_SECTION points at section keys that don't exist in V3_SECTIONS: ${bad.join(", ")}`,
  );
});
