// nav-audit — press every tab, on every page, and confirm it actually renders.
//
// The gap this closes: prior "audits" checked API endpoints (doctor.ts),
// the git/PR write flow (agent-journey.ts), or curl'd a few status codes and
// mis-triaged 404s as "expected". None of them pressed every navigation
// destination and confirmed the page renders. This does — and it's meant to
// run before ANY "production ready" claim, and against every repo you import.
//
// Two modes:
//   (default)            audit the Gluecron self-host repo's every tab
//   --repo owner/name    audit one specific repo's tabs
//   --all-repos          audit EVERY repo on the instance
//   --crawl <baseUrl>    generic same-origin link crawl of ANY site
//                        (works on vapron.ai, gluecron.com, any platform)
//
// Env:
//   GLUECRON_HOST   target instance (default https://gluecron.com)
//   GLUECRON_PAT    bearer token — needed to reach private repos / admin
//   NAV_SLOW_MS     "slow render" threshold (default 8000)
//
// Exit: 0 iff every checked page returns 2xx/3xx and renders without an
// error body. Non-zero (and a FAIL table) otherwise.

const HOST = (process.env.GLUECRON_HOST || "https://gluecron.com").replace(/\/+$/, "");
const PAT = process.env.GLUECRON_PAT?.trim() || "";
const SLOW_MS = Number(process.env.NAV_SLOW_MS || 8000);

const argv = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = argv.indexOf(name);
  return i >= 0 ? argv[i + 1] : undefined;
};

type Row = {
  group: string;
  label: string;
  url: string;
  status: number;
  ms: number;
  ok: boolean;
  note: string;
  body?: string; // captured for 2xx so --crawl can extract links without re-fetching
};

// SECURITY: only ever send the Gluecron PAT to the Gluecron origin. In
// --crawl mode against another platform (vapron.ai, etc.) we must NOT leak
// the token to a different host.
let HOST_ORIGIN = "";
try {
  HOST_ORIGIN = new URL(HOST).origin;
} catch {
  /* HOST malformed — no auth will be attached */
}
function authHeaders(url: string): Record<string, string> {
  if (!PAT) return {};
  try {
    if (new URL(url).origin === HOST_ORIGIN) return { authorization: `Bearer ${PAT}` };
  } catch {
    /* unparseable URL — send nothing */
  }
  return {};
}

// A 200 that renders an error page still counts as broken. Sniff the title +
// obvious error markers.
function errorBody(html: string): string {
  const title = (html.match(/<title>([^<]*)<\/title>/i)?.[1] || "").trim();
  if (/(^|\s)(not found|error|forbidden|something went wrong)(\s|$)/i.test(title)) {
    return `error title: "${title}"`;
  }
  if (/>\s*4\d\d\s*<|Page not found|Internal Server Error|Application error/i.test(html)) {
    return "error content in body";
  }
  return "";
}

async function hit(group: string, label: string, path: string): Promise<Row> {
  const url = path.startsWith("http") ? path : HOST + path;
  const t0 = Date.now();
  try {
    const res = await fetch(url, { headers: authHeaders(url), redirect: "manual" });
    const ms = Date.now() - t0;
    let note = "";
    let ok = res.status >= 200 && res.status < 400;
    let body: string | undefined;
    if (res.status >= 200 && res.status < 300) {
      body = await res.text();
      const eb = errorBody(body);
      if (eb) {
        ok = false;
        note = eb;
      }
    } else if (res.status >= 400) {
      ok = false;
      note = `HTTP ${res.status}`;
    }
    if (ok && ms > SLOW_MS) {
      ok = false;
      note = `slow render ${ms}ms (> ${SLOW_MS}ms)`;
    }
    return { group, label, url, status: res.status, ms, ok, note, body };
  } catch (err) {
    return {
      group,
      label,
      url,
      status: 0,
      ms: Date.now() - t0,
      ok: false,
      note: `fetch failed: ${(err as Error).message}`,
    };
  }
}

// ── Known Gluecron destinations (repo-scoped templates + global) ─────────────

const REPO_TABS: Array<[string, string]> = [
  ["Code", ""],
  ["Issues", "/issues"],
  ["Pull Requests", "/pulls"],
  ["Actions", "/actions"],
  ["Security", "/security/vulnerabilities"],
  ["Insights", "/insights"],
  ["Settings", "/settings"],
  ["AI:Explain", "/explain"],
  ["AI:Ask", "/ask"],
  ["AI:Workspace", "/workspace"],
  ["AI:Spec", "/spec"],
  ["AI:Tests", "/ai/tests"],
  ["AI:NL Search", "/search/nl"],
  ["AI:Debt Map", "/debt-map"],
  ["AI:Archaeology", "/archaeology"],
  ["More:Commits", "/commits"],
  ["More:Discussions", "/discussions"],
  ["More:Wiki", "/wiki"],
  ["More:Projects", "/projects"],
  ["More:Releases", "/releases"],
  ["More:Contributors", "/contributors"],
  ["More:Pulse", "/pulse"],
  ["More:Gates", "/gates"],
  ["More:Deployments", "/cloud-deployments"],
  ["More:Pipeline", "/pipeline"],
  ["More:Agents", "/agents"],
  ["More:Traffic", "/traffic"],
];

const INSIGHT_REPORTS: Array<[string, string]> = [
  ["Insights hub", "/insights"],
  ["DORA", "/insights/dora"],
  ["Velocity", "/insights/velocity"],
  ["Health", "/insights/health"],
  ["Hot files", "/insights/hotfiles"],
  ["Bus factor", "/insights/bus-factor"],
  ["Test gaps", "/insights/test-gaps"],
  ["Engineering", "/insights/engineering"],
  ["Coupling", "/coupling"],
  ["Dependencies", "/dependencies"],
  ["Repo health", "/health"],
];

const SETTINGS_PAGES = [
  "/settings", "/settings/keys", "/settings/signing-keys", "/settings/2fa",
  "/settings/passkeys", "/settings/sessions", "/settings/tokens",
  "/settings/notifications", "/settings/replies", "/settings/billing",
  "/settings/sponsors", "/settings/applications", "/settings/apps",
  "/settings/authorizations", "/settings/agents", "/settings/deploy-targets",
  "/settings/integrations", "/settings/incident-hooks", "/settings/audit",
];

const ADMIN_PAGES = [
  "/admin", "/admin/command", "/admin/ops", "/admin/health", "/admin/env-health",
  "/admin/status", "/admin/self-host", "/admin/diagnose", "/admin/users",
  "/admin/repos", "/admin/flags", "/admin/security", "/admin/soc2",
  "/admin/deletions", "/admin/stripe", "/admin/google-oauth", "/admin/github-oauth",
  "/admin/sso", "/admin/ai-costs", "/admin/growth", "/admin/autopilot",
  "/admin/digests", "/admin/integrations", "/admin/advancement", "/admin/deploys",
  "/admin/autopilot/health",
];

const GLOBAL_PAGES = [
  "/", "/explore", "/dashboard", "/notifications", "/activity", "/pricing",
  "/status", "/help", "/docs", "/changelog",
];

type Spec = { group: string; label: string; path: string };

function repoSpecs(ownerRepo: string): Spec[] {
  const base = `/${ownerRepo}`;
  return [
    ...REPO_TABS.map(([label, sub]) => ({ group: `repo:${ownerRepo}`, label, path: base + sub })),
    ...INSIGHT_REPORTS.map(([label, sub]) => ({ group: `insights:${ownerRepo}`, label, path: base + sub })),
  ];
}

function globalSpecs(): Spec[] {
  return [
    ...GLOBAL_PAGES.map((p) => ({ group: "global", label: p, path: p })),
    ...SETTINGS_PAGES.map((p) => ({ group: "settings", label: p, path: p })),
    ...ADMIN_PAGES.map((p) => ({ group: "admin", label: p, path: p })),
  ];
}

// Bounded-concurrency runner so ~90+ pages finish in seconds, not minutes.
async function runConcurrent(specs: Spec[], concurrency = 10): Promise<Row[]> {
  const rows: Row[] = new Array(specs.length);
  let next = 0;
  async function worker() {
    while (true) {
      const i = next++;
      if (i >= specs.length) return;
      const s = specs[i];
      rows[i] = await hit(s.group, s.label, s.path);
    }
  }
  await Promise.all(Array.from({ length: Math.min(concurrency, specs.length) }, worker));
  return rows;
}

async function listAllRepos(): Promise<string[]> {
  // Best-effort: enumerate the authed user's repos, then their org repos.
  const out = new Set<string>();
  try {
    const me = await fetch(HOST + "/api/v2/user", { headers: authHeaders(HOST + "/api/v2/user") }).then((r) => r.json());
    if (me?.username) {
      const reposUrl = HOST + `/api/v2/users/${encodeURIComponent(me.username)}/repos`;
      const repos = await fetch(reposUrl, { headers: authHeaders(reposUrl) }).then((r) => r.json());
      if (Array.isArray(repos)) for (const r of repos) if (r?.name) out.add(`${me.username}/${r.name}`);
    }
  } catch { /* fall through */ }
  return [...out];
}

// ── Generic same-origin crawler (works on ANY site) ──────────────────────────

const DANGER = /(logout|sign-?out|delete|destroy|revoke|remove|\/raw\/|\/git-|uninstall|disable|reset|purge|cancel|deactivate)/i;

async function crawlSite(startUrl: string, maxPages = 150): Promise<Row[]> {
  const origin = new URL(startUrl).origin;
  const seen = new Set<string>();
  const queue: string[] = [startUrl];
  const rows: Row[] = [];
  while (queue.length && seen.size < maxPages) {
    const url = queue.shift()!;
    if (seen.has(url)) continue;
    seen.add(url);
    const row = await hit("crawl", new URL(url).pathname || "/", url);
    rows.push(row);
    // Only expand links from pages that rendered. Reuse the body hit()
    // already captured — no second fetch of a (possibly slow) page.
    if (row.status >= 200 && row.status < 300 && !row.note && row.body) {
      try {
        const html = row.body;
        const hrefs = [...html.matchAll(/href="([^"#?]+)"/gi)].map((m) => m[1]);
        for (const href of hrefs) {
          if (DANGER.test(href)) continue;
          let abs: string;
          try {
            abs = new URL(href, url).toString();
          } catch {
            continue;
          }
          if (!abs.startsWith(origin)) continue;
          if (DANGER.test(abs)) continue;
          if (!seen.has(abs) && !queue.includes(abs)) queue.push(abs);
        }
      } catch { /* ignore link-extract errors */ }
    }
  }
  return rows;
}

// ── Report ───────────────────────────────────────────────────────────────────

function report(rows: Row[]): number {
  const fails = rows.filter((r) => !r.ok);
  const slow = rows.filter((r) => r.ok && r.ms > SLOW_MS / 2);
  console.log(`\n# nav-audit — ${HOST} — ${new Date().toISOString()}`);
  console.log(`# ${rows.length} pages checked · PAT ${PAT ? "present" : "absent"}\n`);
  if (fails.length) {
    console.log(`## FAILURES (${fails.length})\n`);
    console.log("status | ms    | group / label → url | note");
    console.log("-------+-------+---------------------+-----");
    for (const r of fails) {
      console.log(`${String(r.status).padStart(6)} | ${String(r.ms).padStart(5)} | ${r.group} / ${r.label} → ${r.url} | ${r.note}`);
    }
    console.log("");
  }
  if (slow.length) {
    console.log(`## SLOW (ok but > ${Math.round(SLOW_MS / 2)}ms) — worth a look\n`);
    for (const r of slow) console.log(`  ${String(r.ms).padStart(6)}ms  ${r.group} / ${r.label}`);
    console.log("");
  }
  const passed = rows.length - fails.length;
  console.log(fails.length === 0 ? `nav-audit: ALL GREEN — ${passed}/${rows.length} pages render` : `nav-audit: ${fails.length} FAILURE(S) — ${passed}/${rows.length} render`);
  return fails.length === 0 ? 0 : 1;
}

async function main() {
  let rows: Row[] = [];
  const crawlUrl = flag("--crawl");
  if (crawlUrl) {
    console.log(`crawling ${crawlUrl} (same-origin, GET-only, safe)…`);
    rows = await crawlSite(crawlUrl);
  } else if (argv.includes("--all-repos")) {
    const repos = await listAllRepos();
    console.log(`auditing ${repos.length} repos + global/settings/admin…`);
    const specs = [...globalSpecs(), ...repos.flatMap(repoSpecs)];
    rows = await runConcurrent(specs);
  } else {
    const repo = flag("--repo") || "ccantynz/Gluecron.com";
    console.log(`auditing repo ${repo} + global/settings/admin…`);
    rows = await runConcurrent([...globalSpecs(), ...repoSpecs(repo)]);
  }
  process.exit(report(rows));
}

main();
