/**
 * cli:run-smoke — execute.ts
 *
 * Runtime smoke test : boot dotnet + npm dev, probe every page + every
 * API endpoint declared by the PRD slice, fail on any 4xx/5xx.
 *
 * Exported helpers (for unit tests, no process spawn) :
 *   - deriveProbesFromSlice(slice) — extract page-route probes from
 *     pageSpec.filePath entries.
 *   - extractApiUrlsFromSource(source, file) — extract `'/api/...'`
 *     literals from a generated service / hook file.
 *
 * The main `runSmoke(args)` orchestrates the full flow ; in dryRun
 * mode it skips the boot + probe phases and returns the derived probe
 * list verbatim so callers can lock the URL contract in tests.
 */
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { spawn, type ChildProcess } from 'node:child_process';
import { findFiles } from '../../../../lib/fs.js';
import { collectRegisteredKeys } from '../../../../lib/routes-registry.js';
import { runBrowserProbes } from './browser.js';
import { runInteractionAxis } from './interact.js';
import type {
  RunSmokeArgs,
  SmokeProbe,
  SmokeProbeResult,
  SmokeReport,
  NavMenuCheck,
} from './types.js';

interface PrdSlicePageSpec {
  filePath?: string;
  view?: string;
  entity?: string;
  screenCode?: string;
}

interface PrdSliceShape {
  pageSpecs?: PrdSlicePageSpec[];
}

/**
 * Derive the list of expected page-route probes from `pageSpec.filePath`.
 *
 * Convention : the page file path encodes the route literally. A file at
 * `src/pages/{appCode}/{module}/{section}/{Entity}{View}Page.tsx`
 * exposes a route under `/{appCode}/{module}/{section}/...`. The view
 * suffix tells us whether to probe the index (`list`, `dashboard`) or
 * a parametric path (`detail`, `form`).
 *
 * For list / dashboard / home views : `/{appCode}/{module}/{section}`.
 * For detail / form : `/{appCode}/{module}/{section}/<id-or-new>`. We
 * synthesise a sentinel id `00000000-0000-0000-0000-000000000000` so
 * the probe lands on the actual route (the page is rendered ; the
 * data fetch then returns a 404, which the page handles via the
 * emptyState — that's an OK probe outcome at the routing level).
 */
export function deriveProbesFromSlice(slice: PrdSliceShape): SmokeProbe[] {
  const probes: SmokeProbe[] = [];
  for (const ps of slice.pageSpecs ?? []) {
    if (!ps.filePath) continue;
    const segments = parsePageFilePath(ps.filePath);
    if (!segments) continue;
    const { appCode, module, section, view } = segments;
    const base = `/${appCode}/${module}/${section}`;
    const url =
      view === 'detail' || view === 'form'
        ? `${base}/00000000-0000-0000-0000-000000000000`
        : base;
    probes.push({
      source: 'pageSpec',
      url,
      verb: 'GET',
      expectedBy: ps.filePath,
    });
  }
  return probes;
}

interface PagePathSegments {
  appCode: string;
  module: string;
  section: string;
  view: string;
}

/** Parse `src/pages/{appCode}/{module}/{section}/{Entity}{View}Page.tsx`. */
function parsePageFilePath(filePath: string): PagePathSegments | null {
  const norm = filePath.replace(/\\/g, '/');
  const match = /^src\/pages\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)Page\.tsx$/.exec(norm);
  if (!match) return null;
  const [, appCode, module, section, entityPlusView] = match;
  // Heuristic : the View suffix is the trailing PascalCase token of the
  // file's base name. We map known suffixes to their lowercase view code.
  const suffixes = ['List', 'Detail', 'Form', 'Dashboard', 'Card', 'Kanban', 'AppHome', 'ModuleHome', 'SectionHome'];
  let view = 'list';
  for (const s of suffixes) {
    if (entityPlusView.endsWith(s)) {
      view = s.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
      break;
    }
  }
  return { appCode: appCode.toLowerCase(), module, section, view };
}

/**
 * Extract `/api/...` URL literals from a generated source file.
 *
 * Matches :
 *   api.get(`${API_PATH}/dashboard/consolidated`, …)
 *   api.post('/api/budgets/budgets', …)
 *   axios.put<…>(`${BASE}/${id}`, …)
 *
 * Returns each unique URL with its inferred HTTP verb. Template literals
 * with `${...}` interpolations are normalised : the variable substitution
 * is replaced by a sentinel (`/_/`) so the probe URL hits a real route
 * (relying on the server's route table to match parametric segments).
 */
export function extractApiUrlsFromSource(source: string, fromFile: string): SmokeProbe[] {
  const probes: SmokeProbe[] = [];
  const callRe = /\bapi\.|smartstackClient|axios|apiClient/;
  if (!callRe.test(source)) return probes;
  // Statically resolve `const NAME = '<literal>'` declarations so template
  // literals referencing them (\${API_PATH}/...) can be expanded into the
  // concrete URL the runtime will hit.
  const stringConsts = new Map<string, string>();
  const constRe = /\bconst\s+(\w+)\s*=\s*['"]([^'"]+)['"]\s*;?/g;
  let cm: RegExpExecArray | null;
  while ((cm = constRe.exec(source)) !== null) {
    stringConsts.set(cm[1], cm[2]);
  }
  // Common shape : `<obj>.<verb>(<url>, ...)`. Match the URL literal
  // (either single/double-quoted or back-ticked) right after the verb.
  // The optional `<...>` generic args are matched as a whole optional
  // group (the original `<[^>]*>?` only made the closing `>` optional and
  // broke on calls without generics).
  const verbRe = /\.(get|post|put|delete|patch)\s*(?:<[^>]*>)?\s*\(\s*([`'"][^`'"]+[`'"])/gi;
  let m: RegExpExecArray | null;
  while ((m = verbRe.exec(source)) !== null) {
    const verbStr = m[1].toUpperCase() as SmokeProbe['verb'];
    const raw = m[2].slice(1, -1);
    const url = expandTemplateLiteral(raw, stringConsts);
    if (!url.startsWith('/')) continue;
    probes.push({
      source: 'apiClient',
      url,
      verb: verbStr,
      expectedBy: fromFile,
    });
  }
  return probes;
}

/**
 * Expand `${VAR}` placeholders inside a template literal :
 *  - if `VAR` is a known string const in scope, substitute its value ;
 *  - otherwise substitute the sentinel `_` (so the resulting URL still
 *    has a sane shape for the route-table probe).
 *
 * When the leading placeholder doesn't resolve (no matching const) AND
 * the URL would start with a bare sentinel, we prepend `/api/` so the
 * probe lands on the backend host (most apiClient URLs start with
 * `${API_PATH}` which is itself `/api/...`).
 */
function expandTemplateLiteral(raw: string, scope: Map<string, string>): string {
  const expanded = raw.replace(/\$\{([^}]+)\}/g, (_, expr) => {
    const trimmed = expr.trim();
    return scope.get(trimmed) ?? '_';
  });
  return expanded.replace(/^_(?=\/)/, '/api/_');
}

/** Find every generated service / hook file and extract API URLs from it. */
export async function discoverApiProbes(projectPath: string): Promise<SmokeProbe[]> {
  const webRoot = await findWebRoot(projectPath);
  if (!webRoot) return [];
  // scaffold-api-client writes services to `src/features/{app}/{module}/{entity}/services/`
  // (NOT `src/services/api/**`, which never existed — the historical glob found 0
  // service files, so the smoke probe was blind to every generated API URL). Scan the
  // real location; keep the legacy glob as a harmless fallback.
  const serviceFiles = await findFiles('src/features/**/services/**/*.ts', { cwd: webRoot });
  const legacyServiceFiles = await findFiles('src/services/api/**/*.ts', { cwd: webRoot });
  const hookFiles = await findFiles('src/features/**/hooks/**/*.ts', { cwd: webRoot });
  const candidates = [...serviceFiles, ...legacyServiceFiles, ...hookFiles];
  const probes: SmokeProbe[] = [];
  for (const f of candidates) {
    if (!existsSync(f)) continue;
    const src = readFileSync(f, 'utf8');
    probes.push(...extractApiUrlsFromSource(src, path.relative(projectPath, f)));
  }
  return dedupeProbes(probes);
}

function dedupeProbes(probes: SmokeProbe[]): SmokeProbe[] {
  const seen = new Set<string>();
  const out: SmokeProbe[] = [];
  for (const p of probes) {
    const k = `${p.verb} ${p.url}`;
    if (seen.has(k)) continue;
    seen.add(k);
    out.push(p);
  }
  return out;
}

async function findWebRoot(projectPath: string): Promise<string | null> {
  // Convention : `<projectPath>/web/<app>-web` (cf. SmartStack scaffolders).
  const webDir = path.join(projectPath, 'web');
  if (!existsSync(webDir)) return null;
  // Pick the first sub-dir matching *-web.
  const candidates = await findFiles('*-web/package.json', { cwd: webDir });
  if (candidates.length === 0) return null;
  return path.dirname(candidates[0]);
}

/**
 * Probe a single URL on the host (anonymous HTTP). Returns a structured
 * result with status code + duration. Wraps fetch with a short timeout
 * so a hung backend doesn't deadlock the smoke run.
 */
async function probe(url: string, verb: SmokeProbe['verb'], hostBase: string, timeoutMs: number): Promise<{ status: number | null; durationMs: number; reason?: string }> {
  const start = Date.now();
  const controller = new AbortController();
  const t = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const res = await fetch(`${hostBase}${url}`, { method: verb, signal: controller.signal });
    return { status: res.status, durationMs: Date.now() - start };
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    return {
      status: null,
      durationMs: Date.now() - start,
      reason: message.includes('aborted') ? 'timeout' : 'network-error',
    };
  } finally {
    clearTimeout(t);
  }
}

/**
 * Run the full smoke test : derive probe list, optionally boot + probe,
 * return the structured report. Boot orchestration is intentionally
 * isolated behind `dryRun: false` so unit tests can lock the URL
 * extraction without spawning processes.
 */
export async function runSmoke(args: RunSmokeArgs): Promise<SmokeReport> {
  // 1. Load PRD slice.
  if (!existsSync(args.prdSlice)) {
    throw new Error(`prdSlice not found at ${args.prdSlice}`);
  }
  const slice = JSON.parse(readFileSync(args.prdSlice, 'utf8')) as PrdSliceShape;

  // 2. Derive expected probes.
  const pageProbes = deriveProbesFromSlice(slice);
  const apiProbes = await discoverApiProbes(args.projectPath);
  const allProbes = dedupeProbes([...pageProbes, ...apiProbes]);

  if (args.dryRun) {
    const probes: SmokeProbeResult[] = allProbes.map((p) => ({
      ...p,
      status: null,
      durationMs: 0,
      failed: false,
      failureReason: 'dry-run',
    }));
    return {
      passed: true,
      backendBootMs: null,
      frontendBootMs: null,
      totalProbes: probes.length,
      passedProbes: 0,
      failedProbes: 0,
      skippedProbes: probes.length,
      probes,
      failures: [],
      browser: { ran: false, available: false, pages: [], failures: [] },
      navMenu: { ran: false, ok: true, mismatches: [] },
      interaction: { ran: false, authenticated: false, actions: [], failures: [] },
    };
  }

  // 3. Boot orchestration (Wave 2.3, 2026-05-27).
  //
  //    When selfBoot=true (default): spawn `dotnet run --project src/<Ns>.Api`
  //    + `npm run dev` in background, poll their health endpoints, run the
  //    probes, then tear both processes down on the way out (try/finally
  //    block ensures cleanup even if probes throw).
  //
  //    When selfBoot=false (legacy): the caller manages the processes
  //    externally — we skip the boot phase and probe directly.
  const frontendHost = `http://localhost:${args.frontendPort}`;
  const backendHost = `http://localhost:${args.backendPort}`;

  let backendBootMs: number | null = null;
  let frontendBootMs: number | null = null;
  let backendProc: ChildProcess | null = null;
  let frontendProc: ChildProcess | null = null;

  try {
    if (args.selfBoot) {
      // Best-effort discovery of the .NET project entrypoint.
      const apiCsproj = await firstFile('src/*.Api/*.Api.csproj', args.projectPath)
        ?? await firstFile('src/**/*.Api.csproj', args.projectPath);
      const webRoot = await firstFile('web/*-web/package.json', args.projectPath);

      if (apiCsproj) {
        const t0 = Date.now();
        backendProc = spawn('dotnet', ['run', '--project', apiCsproj], {
          cwd: args.projectPath,
          stdio: 'ignore',
          env: { ...process.env, ASPNETCORE_URLS: backendHost },
          shell: process.platform === 'win32',
          detached: false,
        });
        const ok = await waitForHttp(`${backendHost}/health`, args.timeoutMs);
        backendBootMs = ok ? Date.now() - t0 : null;
        if (!ok) {
          return failReport('backend boot timeout', allProbes, backendBootMs, frontendBootMs);
        }
      }

      if (webRoot) {
        const t0 = Date.now();
        frontendProc = spawn('npm', ['run', 'dev', '--', '--port', String(args.frontendPort)], {
          cwd: path.dirname(webRoot),
          stdio: 'ignore',
          shell: process.platform === 'win32',
          detached: false,
        });
        const ok = await waitForHttp(frontendHost, args.timeoutMs);
        frontendBootMs = ok ? Date.now() - t0 : null;
        if (!ok) {
          return failReport('frontend boot timeout', allProbes, backendBootMs, frontendBootMs);
        }
      }
    }

    const results: SmokeProbeResult[] = [];
    for (const p of allProbes) {
      const isApi = p.url.startsWith('/api/');
      const host = isApi ? backendHost : frontendHost;
      const { status, durationMs, reason } = await probe(p.url, p.verb, host, args.timeoutMs);
      // Probes are anonymous. A 401/403 means the route EXISTS but is auth-gated
      // ([Authorize]) — that's a PASS: we assert the route is served, not that
      // anonymous access works (real-token coverage is the /uat skill's job). Only
      // UNEXPECTED 4xx/5xx fail: 404 (missing route — the NavRoute mismatch this gate
      // exists to catch), 405 (wrong verb), 400 (broken contract/config) and 5xx.
      const authGated = status === 401 || status === 403;
      const failed = status === null
        ? true
        : !authGated && status >= 400 && status < 600;
      const failureReason = !failed
        ? undefined
        : reason ?? (status && status >= 500 ? 'status-5xx' : 'status-4xx');
      results.push({ ...p, status, durationMs, failed, failureReason });
    }

    const failed = results.filter((r) => r.failed);

    // ─── Browser pass (Wave — the class HTTP probing can't see) ──────────────
    // Only meaningful when a frontend is actually up: selfBoot booted it
    // (frontendBootMs set), or the caller manages it externally (!selfBoot).
    const frontendUp = !args.selfBoot || frontendBootMs !== null;
    let browser: SmokeReport['browser'] = { ran: false, available: false, pages: [], failures: [] };
    if (frontendUp && !args.skipBrowser) {
      const pageRoutes = pageProbes
        .map((p) => p.url)
        .filter((u) => !u.includes('00000000-0000')); // list/home/dashboard only — skip sentinel-id detail probes
      const outcome = await runBrowserProbes(frontendHost, pageRoutes, args.timeoutMs);
      browser = {
        ran: true,
        available: outcome.available,
        reason: outcome.reason,
        pages: outcome.pages,
        failures: outcome.pages.filter((p) => !p.ok),
      };
    }

    // ─── Nav-menu app-consistency (BUG A) — best-effort, needs a live backend ──
    const navMenu = await checkNavMenu(backendHost, args.timeoutMs, await loadRegisteredComponentKeys(args.projectPath));

    // ─── Interaction axis (4b) — custom-action contract probes ────────────────
    // Reads the module pagespecs, fires each custom action with a synthesized
    // VALID body at the sentinel id, and classifies 415/405/5xx as failures. Only
    // runs when a backend booted + --module-root supplied + not disabled.
    const backendUp = !args.selfBoot || backendBootMs !== null;
    let interaction: SmokeReport['interaction'] = { ran: false, authenticated: false, actions: [], failures: [] };
    if (backendUp && args.exerciseActions) {
      interaction = await runInteractionAxis(backendHost, args.moduleRoot, { token: args.adminToken });
    }

    // The gate fails on ANY axis: a failed HTTP probe, a failed browser page, an
    // UNAVAILABLE browser (never a silent pass — the caller records
    // `smoke.browser-unavailable`), a real nav-menu app mismatch, or a custom-action
    // contract failure (415/405/5xx on a valid body). An interaction axis that could
    // not authenticate (all probes auth-gated) is a NOTE, not a hard failure.
    const browserFailed = browser.ran && (!browser.available || browser.failures.length > 0);
    const passed = failed.length === 0 && !browserFailed && navMenu.ok && interaction.failures.length === 0;

    return {
      passed,
      backendBootMs,
      frontendBootMs,
      totalProbes: results.length,
      passedProbes: results.length - failed.length,
      failedProbes: failed.length,
      skippedProbes: 0,
      probes: results,
      failures: failed,
      browser,
      navMenu,
      interaction,
    };
  } finally {
    // Best-effort teardown. SIGTERM → wait up to 5 s → SIGKILL.
    await teardown(backendProc);
    await teardown(frontendProc);
  }
}

async function firstFile(glob: string, cwd: string): Promise<string | null> {
  const matches = await findFiles(glob, { cwd });
  return matches[0] ?? null;
}

async function waitForHttp(url: string, timeoutMs: number): Promise<boolean> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    try {
      const ctrl = new AbortController();
      const t = setTimeout(() => ctrl.abort(), 2000);
      const res = await fetch(url, { signal: ctrl.signal });
      clearTimeout(t);
      if (res.status < 500) return true;
    } catch {
      // connection refused / ECONNRESET / abort — keep polling.
    }
    await sleep(500);
  }
  return false;
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function teardown(proc: ChildProcess | null): Promise<void> {
  if (!proc || proc.killed) return;
  try {
    proc.kill('SIGTERM');
    const exited = await Promise.race([
      new Promise<boolean>((resolve) => proc.once('exit', () => resolve(true))),
      sleep(5000).then(() => false),
    ]);
    if (!exited) proc.kill('SIGKILL');
  } catch {
    // best-effort
  }
}

function failReport(
  reason: string,
  allProbes: SmokeProbe[],
  backendBootMs: number | null,
  frontendBootMs: number | null,
): SmokeReport {
  const skipped: SmokeProbeResult[] = allProbes.map((p) => ({
    ...p,
    status: null,
    durationMs: 0,
    failed: true,
    failureReason: reason,
  }));
  return {
    passed: false,
    backendBootMs,
    frontendBootMs,
    totalProbes: skipped.length,
    passedProbes: 0,
    failedProbes: skipped.length,
    skippedProbes: 0,
    probes: skipped,
    failures: skipped,
    browser: { ran: false, available: false, pages: [], failures: [] },
    navMenu: { ran: false, ok: true, mismatches: [] },
    interaction: { ran: false, authenticated: false, actions: [], failures: [] },
  };
}

/**
 * Best-effort runtime guard for BUG A: fetch GET /api/navigation/menu and assert
 * every node's route app-segment matches its componentKey app-prefix. The
 * cross-app merge shows up as a section whose route is `/rh/…` while its
 * componentKey is `clients.…`. Anonymous + auth-gated menus return non-200 → the
 * check is SKIPPED (ok, with a reason), never a false failure. Only a real 200
 * response with a mismatch fails the gate.
 */
async function checkNavMenu(backendHost: string, timeoutMs: number, registeredKeys: ReadonlySet<string> | null): Promise<NavMenuCheck> {
  const controller = new AbortController();
  const t = setTimeout(() => controller.abort(), Math.min(timeoutMs, 10_000));
  try {
    const res = await fetch(`${backendHost}/api/navigation/menu`, { signal: controller.signal });
    if (res.status !== 200) {
      return {
        ran: true,
        ok: true,
        mismatches: [],
        skippedReason: `GET /api/navigation/menu returned ${res.status} (likely auth-gated for an anonymous probe) — app-consistency not verified at runtime; the seed-level fix + aggregator guard cover it statically.`,
      };
    }
    const body: unknown = await res.json().catch(() => null);
    if (body == null) {
      return { ran: true, ok: true, mismatches: [], skippedReason: 'menu response was not JSON.' };
    }
    const unique = findNavMenuMismatches(body);
    const unregistered = registeredKeys === null ? [] : findUnregisteredMenuKeys(body, registeredKeys);
    return { ran: true, ok: unique.length === 0 && unregistered.length === 0, mismatches: unique, unregisteredKeys: unregistered };
  } catch (e) {
    return {
      ran: true,
      ok: true,
      mismatches: [],
      skippedReason: `menu fetch failed: ${(e as Error).message}`,
    };
  } finally {
    clearTimeout(t);
  }
}

/**
 * Walk a `/api/navigation/menu` payload and return the cross-app app-prefix
 * mismatches (BUG A). A node with a `route` whose first path segment differs
 * from its `componentKey`/`key` app-prefix means a section got attached to the
 * wrong app's module during seeding. Pure + shape-tolerant (exported for tests).
 */
export function findNavMenuMismatches(body: unknown): string[] {
  const mismatches: string[] = [];
  const visit = (node: unknown): void => {
    if (node == null) return;
    if (Array.isArray(node)) {
      for (const n of node) visit(n);
      return;
    }
    if (typeof node !== 'object') return;
    const o = node as Record<string, unknown>;
    const route = typeof o.route === 'string' ? o.route : undefined;
    const key =
      typeof o.componentKey === 'string'
        ? o.componentKey
        : typeof o.key === 'string'
          ? o.key
          : undefined;
    if (route && key && route.startsWith('/') && key.includes('.')) {
      const routeApp = route.split('/').filter(Boolean)[0];
      const keyApp = key.split('.')[0];
      if (routeApp && keyApp && routeApp !== keyApp) {
        mismatches.push(
          `componentKey "${key}" (app "${keyApp}") carries route "${route}" (app "${routeApp}") — cross-app nav merge (BUG A).`,
        );
      }
    }
    for (const v of Object.values(o)) visit(v);
  };
  visit(body);
  return [...new Set(mismatches)];
}


/**
 * Every componentKey the project's generated registries register — read from
 * `web/<app>-web/src/extensions/*.ts` with the shared PAGE_REGISTER_RE
 * (lib/routes-registry). Null when no registry is readable (check skipped) —
 * "no registries found" must never fail a legacy project.
 */
async function loadRegisteredComponentKeys(projectPath: string): Promise<ReadonlySet<string> | null> {
  const webRoot = await findWebRoot(projectPath);
  if (!webRoot) return null;
  const extDir = path.join(webRoot, 'src', 'extensions');
  if (!existsSync(extDir)) return null;
  const keys = new Set<string>();
  for (const abs of await findFiles('*.ts', { cwd: extDir })) {
    const src = readFileSync(abs, 'utf-8');
    for (const k of collectRegisteredKeys(src)) keys.add(k);
  }
  return keys.size > 0 ? keys : null;
}

/**
 * Menu componentKeys that resolve NO PageRegistry.register — the runtime net
 * behind SCR-020: a section seeded with N sibling resources and no root page
 * ships a menu entry that mounts nothing (perpetual spinner). Pure +
 * shape-tolerant (exported for tests). Only clickable nodes count (a `route`
 * plus a dotted key), the same signal findNavMenuMismatches keys on.
 */
export function findUnregisteredMenuKeys(body: unknown, registered: ReadonlySet<string>): string[] {
  const missing: string[] = [];
  const visit = (node: unknown): void => {
    if (node == null) return;
    if (Array.isArray(node)) { for (const n of node) visit(n); return; }
    if (typeof node !== 'object') return;
    const o = node as Record<string, unknown>;
    const route = typeof o.route === 'string' ? o.route : undefined;
    const key = typeof o.componentKey === 'string' ? o.componentKey
      : typeof o.key === 'string' ? o.key : undefined;
    if (route && key && route.startsWith('/') && key.includes('.') && !registered.has(key)) {
      missing.push(
        `componentKey "${key}" (route "${route}") has NO PageRegistry.register — the menu entry mounts nothing ` +
        `(blank screen / perpetual spinner). Declare a SmartSectionHome for the section (SCR-020) or scaffold the page, then re-run scaffold-routes.`,
      );
    }
    for (const v of Object.values(o)) visit(v);
  };
  visit(body);
  return [...new Set(missing)];
}
/** Render the on-disk `_audit/smoke-<module>.md` report. Its PRESENCE is the
 *  proof the gate ran; its content lists every failing axis. */
export function renderSmokeMarkdown(report: SmokeReport, moduleCode: string): string {
  const b = report.browser;
  const lines: string[] = [];
  lines.push(`# run-smoke — ${moduleCode}`);
  lines.push('');
  lines.push(`- Result: **${report.passed ? 'PASS' : 'FAIL'}**`);
  lines.push(`- HTTP probes: ${report.passedProbes}/${report.totalProbes} passed (${report.failedProbes} failed)`);
  lines.push(
    `- Browser pass: ${
      b.ran
        ? b.available
          ? `${b.pages.length - b.failures.length}/${b.pages.length} pages ok (${b.failures.length} failed)`
          : `UNAVAILABLE — ${b.reason ?? ''}`
        : 'not run'
    }`,
  );
  lines.push(
    `- Nav-menu app-consistency: ${
      report.navMenu.ran
        ? report.navMenu.skippedReason
          ? `skipped (${report.navMenu.skippedReason})`
          : report.navMenu.ok
            ? 'ok'
            : `${report.navMenu.mismatches.length} MISMATCH`
        : 'not run'
    }`,
  );
  const ix = report.interaction;
  lines.push(
    `- Action contract (axis 4b): ${
      ix.ran
        ? `${ix.actions.length - ix.failures.length}/${ix.actions.length} probes ok (${ix.failures.length} failed)` +
          (ix.authenticated ? '' : ix.reason ? ` — ${ix.reason}` : ' — anonymous')
        : 'not run'
    }`,
  );
  lines.push(`- Backend boot: ${report.backendBootMs ?? 'n/a'} ms · Frontend boot: ${report.frontendBootMs ?? 'n/a'} ms`);
  lines.push('');
  if (report.failures.length) {
    lines.push('## Failed HTTP probes');
    for (const f of report.failures) {
      lines.push(
        `- \`${f.verb} ${f.url}\` → ${f.status ?? 'no-response'} (${f.failureReason ?? 'unknown'})${f.expectedBy ? ` — ${f.expectedBy}` : ''}`,
      );
    }
    lines.push('');
  }
  if (b.failures.length) {
    lines.push('## Failed browser pages');
    for (const p of b.failures) {
      lines.push(`- \`${p.url}\` — ${p.accessState}${p.viteOverlay ? ' · vite-error-overlay' : ''}`);
      for (const e of p.consoleErrors.slice(0, 5)) lines.push(`  - console: ${e}`);
      for (const r of p.failedRequests.slice(0, 5)) lines.push(`  - request ${r.status}: ${r.url}`);
    }
    lines.push('');
  }
  if (b.ran && !b.available) {
    lines.push('## Browser unavailable (smoke.browser-unavailable)');
    lines.push(b.reason ?? '');
    lines.push('');
  }
  if (!report.navMenu.ok) {
    lines.push('## Nav-menu app mismatches (BUG A)');
    for (const m of report.navMenu.mismatches) lines.push(`- ${m}`);
    for (const m of report.navMenu.unregisteredKeys ?? []) lines.push(`- ${m}`);
    lines.push('');
  }
  if (ix.failures.length) {
    lines.push('## Failed action-contract probes (axis 4b)');
    for (const a of ix.failures) {
      lines.push(`- \`${a.httpMethod} ${a.url}\` (${a.entity}.${a.code}, ${a.scope}) → ${a.status ?? 'no-response'} (${a.reason ?? 'unknown'})`);
    }
    lines.push('');
  }
  if (ix.ran && !ix.authenticated && ix.reason) {
    lines.push('## Action axis not authenticated (smoke.interaction-unavailable)');
    lines.push(ix.reason);
    lines.push('');
  }
  return lines.join('\n') + '\n';
}
