// tests/background/no-test-hooks-in-prod-bundle.test.ts
//
// Tier-1 hook-leak gate (Plan 01-11 Task 1) — sibling of
// `sw-bundle-import.test.ts`. Both gates inspect the BUILT `dist/`
// artifact for an invariant the SOURCE alone cannot prove.
//
// What this gate enforces — the security-critical invariant T-1-11-01:
//
//   Plan 01-11 introduces test-only "hook" surfaces under `src/test-hooks/`
//   that expose internal SW + offscreen state (captured chrome.* handler
//   refs, MediaStream getter, simulated user-stopped-sharing trigger) to
//   the Puppeteer harness via a global named `__mokoshTest`. The hooks
//   ship in the TEST bundle (`dist-test/`) and MUST NOT ship in the
//   PRODUCTION bundle (`dist/`) — leaking them would expose Bug B's
//   `simulateUserStop` path + the captured `onStartup` handler ref to
//   any page that can `eval` against the extension's SW.
//
//   The leak is prevented by a Vite mode gate: each hook import in
//   src/background/index.ts + src/offscreen/recorder.ts is wrapped in
//   `if (import.meta.env.MODE === 'test') { await import('../test-hooks/...'); }`.
//   Vite statically replaces `import.meta.env.MODE` at build time
//   (production mode → `'production'`); the `'production' === 'test'`
//   comparison is a static dead branch and Rollup tree-shakes the
//   `await import` away entirely. That tree-shake is what THIS GATE
//   verifies — by greping the built artifact tree for the hook surface
//   strings and asserting they are absent.
//
// Why a unit-level gate IN ADDITION TO the harness's assertion 0:
//   The harness's assertion 0 runs only when the harness runs (`npm run
//   test:uat`), which requires a Chrome download + ~90s wall clock. The
//   unit gate runs as part of the regular `npm test` pass — every
//   developer's pre-push hook + every CI vitest job catches the leak in
//   <15s. Belt + suspenders per Plan 01-11 RESEARCH §6 + the orchestrator-
//   loaded `feedback-pre-checkpoint-bundle-gates.md` memory: any future
//   plan executor whose work surfaces a SW build MUST keep this gate
//   GREEN before any operator-empirical checkpoint.
//
// Polarity note: the gate is GREEN today (no hooks land until Plan 01-11
// Task 2) AND must STAY GREEN after Task 2 lands them. The test is
// committed BEFORE the hooks ship so the invariant is asserted from day
// one — eliminating any window-of-vulnerability where the production
// bundle could carry leaked hooks unnoticed.
//
// Surface inventory enforced (each MUST be absent from any file under
// dist/). Plan 01-13 Wave 0 updated this list for the Approach-B
// architecture (extension-internal harness page + offscreen-side
// synthetic stream + chrome.runtime.sendMessage bridge), replacing the
// 01-11 Approach-A SW-side instrumentation surface. The 01-11 entries
// `simulateUserStop` (renamed to `dispatchEndedOnTrack` to match the
// W3C dispatchEvent semantics per RESEARCH §7 BLOCKER) is dropped.
//
//   - `__mokoshTest`                — the global surface name itself
//   - `setCurrentStream`            — Plan 01-11 Task 2 offscreen wire (retained)
//   - `setSegmentCountGetter`       — Plan 01-11 Task 7 offscreen wire (retained)
//   - `installFakeDisplayMedia`     — 01-13 synthetic getDisplayMedia install
//   - `uninstallFakeDisplayMedia`   — 01-13 synthetic getDisplayMedia teardown
//   - `dispatchEndedOnTrack`        — 01-13 Bug B simulate via dispatchEvent
//                                     (replaces Approach-A `simulateUserStop`)
//   - `getSegmentCount`             — Plan 01-11 Task 7 segments-count getter (retained)
//   - `__mokoshOffscreenQuery`      — 01-13 page→offscreen bridge message type
//   - `get-display-surface`         — 01-13 Wave 3A bridge op string (A3 contract)
//   - `get-segment-count`           — 01-13 Wave 3D bridge op string (A11 contract)
//   - `lastGetDisplayMediaConstraints` — 01-14 A23 offscreen-side cell name
//   - `get-last-getDisplayMedia-constraints` — 01-14 A23 bridge op string
//
// Total: 12 surface strings. Each MUST be absent from EVERY file under
// `dist/` post-build. The list is mirrored by the harness's A0
// assertion (tests/uat/harness.test.ts in Wave 3A; Plan 01-14 lockstep
// extension) so the same invariant is enforced at unit-test time
// (fast, every CI run) AND at UAT-harness time (belt+suspenders per
// the orchestrator-loaded `feedback-pre-checkpoint-bundle-gates.md`
// memory).
//
// Implementation mirrors `sw-bundle-import.test.ts`'s execFile pattern:
//   - Spawn `npm run build` via execFile so the build is reproducible
//     and the gate runs against a known-clean artifact.
//   - Skip the build if `process.env.SKIP_BUILD === '1'` — developer
//     escape hatch when iterating on the gate itself.
//   - Recursively walk `dist/` reading files synchronously (the tree is
//     small; ~10 chunks; ~500 KB total).
//   - For each forbidden string, count total occurrences and report the
//     offending file paths on failure.
//
// References:
//   - Vite mode + `import.meta.env.MODE`:
//     https://vite.dev/guide/env-and-mode.html
//   - Rollup tree-shaking + dead-branch elimination:
//     https://rollupjs.org/configuration-options/#treeshake
//   - Node `child_process.execFile`:
//     https://nodejs.org/api/child_process.html#child_processexecfilefile-args-options-callback
//   - Node `fs.readdirSync` + `withFileTypes`:
//     https://nodejs.org/api/fs.html#fsreaddirsyncpath-options

import { execFile } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { resolve as resolvePath } from 'node:path';
import { promisify } from 'node:util';

import { describe, expect, it } from 'vitest';

const execFileAsync = promisify(execFile);

/**
 * Surface strings the gate forbids in any file under `dist/`. Order is
 * preserved in failure diagnostics so the report is stable across runs.
 * Each entry's rationale lives in the file header above.
 */
const FORBIDDEN_HOOK_STRINGS: ReadonlyArray<string> = [
  '__mokoshTest',
  'setCurrentStream',
  'setSegmentCountGetter',
  'installFakeDisplayMedia',
  'uninstallFakeDisplayMedia',
  'dispatchEndedOnTrack',
  'getSegmentCount',
  '__mokoshOffscreenQuery',
  'get-display-surface',
  'get-segment-count',
  // Plan 01-14 A23 surface — lockstep with UAT A0 inventory at
  // tests/uat/harness.test.ts:85. Verifies the `__MOKOSH_UAT__` Vite
  // define-token dead-branch tree-shake correctly elides the new
  // `lastGetDisplayMediaConstraints` cell + the `get-last-getDisplayMedia-constraints`
  // bridge op from production `dist/` (T-01-14-04 mitigation).
  'lastGetDisplayMediaConstraints',
  'get-last-getDisplayMedia-constraints',
];

/** How long the build child has to finish (`npm run build` is ~10s).
 *  Generous cap; if it blows past this something else is wrong. */
const BUILD_TIMEOUT_MS = 60_000;

/** Absolute path to the production output directory. */
const DIST_DIR = resolvePath(process.cwd(), 'dist');

/**
 * One match in one file. Held in a flat array per forbidden string so
 * the failure message can enumerate every (file, count) pair.
 */
interface ForbiddenMatch {
  readonly filePath: string;
  readonly count: number;
}

/**
 * Recursively collect every regular file under `root`. Returns absolute
 * paths. Skips symlinks defensively (none expected in the Vite output
 * tree, but cheap to guard against).
 *
 * @param root - Absolute directory path to walk.
 * @returns Sorted list of absolute file paths under `root`.
 */
function listAllFilesRecursive(root: string): ReadonlyArray<string> {
  const accumulator: string[] = [];
  const stack: string[] = [root];
  while (stack.length > 0) {
    const dir = stack.pop()!;
    const entries = readdirSync(dir, { withFileTypes: true });
    for (const entry of entries) {
      const fullPath = resolvePath(dir, entry.name);
      if (entry.isSymbolicLink()) {
        continue;
      }
      if (entry.isDirectory()) {
        stack.push(fullPath);
      } else if (entry.isFile()) {
        accumulator.push(fullPath);
      }
    }
  }
  return accumulator.sort();
}

/**
 * Count occurrences of `needle` inside the given file's text content.
 * Returns 0 when the file is binary-ish (no occurrences of a likely-text
 * sentinel character class). Vite emits JS/CSS/HTML/JSON — all UTF-8 —
 * plus copies of PNG icons. We skip files whose extensions clearly mark
 * them as binary so readFileSync('utf8') does not return mojibake that
 * could accidentally match `needle`.
 *
 * @param filePath - Absolute file path to scan.
 * @param needle   - Literal substring to count.
 * @returns Total occurrences of `needle` in the file's text.
 */
function countOccurrencesInFile(filePath: string, needle: string): number {
  const binaryExtensions = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.woff', '.woff2', '.ttf', '.otf']);
  const dotIdx = filePath.lastIndexOf('.');
  const ext = dotIdx >= 0 ? filePath.substring(dotIdx).toLowerCase() : '';
  if (binaryExtensions.has(ext)) {
    return 0;
  }
  const stat = statSync(filePath);
  if (stat.size === 0) {
    return 0;
  }
  const text = readFileSync(filePath, 'utf8');
  let count = 0;
  let from = 0;
  for (;;) {
    const idx = text.indexOf(needle, from);
    if (idx < 0) {
      break;
    }
    count += 1;
    from = idx + needle.length;
  }
  return count;
}

/**
 * Walk `dist/` and find every file containing `needle`. Returns an
 * array of (file, count) pairs sorted by file path. Empty when the
 * needle is absent — that is the GREEN-gate condition.
 *
 * @param needle - Literal substring to grep for.
 * @returns List of matches; empty array on absence.
 */
function findMatchesInDist(needle: string): ReadonlyArray<ForbiddenMatch> {
  const files = listAllFilesRecursive(DIST_DIR);
  const matches: ForbiddenMatch[] = [];
  for (const filePath of files) {
    const count = countOccurrencesInFile(filePath, needle);
    if (count > 0) {
      matches.push({ filePath, count });
    }
  }
  return matches;
}

/**
 * Spawn `npm run build` in a child process; reject on non-zero exit OR
 * on timeout. Inherits parent env (we want the same `NODE_OPTIONS` etc.
 * the developer set), but suppresses Node experimental warnings to
 * keep vitest's failure output readable.
 *
 * @returns void on success; throws with build stderr captured on failure.
 */
async function runProductionBuild(): Promise<void> {
  await execFileAsync('npm', ['run', 'build'], {
    timeout: BUILD_TIMEOUT_MS,
    maxBuffer: 16 * 1024 * 1024,
    env: { ...process.env, NODE_NO_WARNINGS: '1' },
  });
}

describe('production bundle has no test-hook leaks (Tier-1 gate — T-1-11-01)', () => {
  // Plan 01-10 closure: `npm run build` slowed from ~2.88s → ~5.28s after
  // the welcome page assets landed (welcome HTML + SVG ?url import + 8 WOFF2
  // fonts shipped in d48a715 / 49f087f). Vitest's default 5000ms it() ceiling
  // now races the build child. The EXEC-level `BUILD_TIMEOUT_MS = 60_000`
  // bound on the child process still applies; this it()-level 30000ms
  // ceiling is the surrounding-block companion. 30s is generously above the
  // observed 5.28s + npm overhead and well below the 60s exec bound.
  it('npm run build completes and dist/ exists with at least one chunk', async () => {
    if (process.env.SKIP_BUILD !== '1') {
      await runProductionBuild();
    }
    expect(
      existsSync(DIST_DIR),
      `dist/ missing at ${DIST_DIR}. Either npm run build failed or SKIP_BUILD=1 was set ` +
        `without a pre-existing build. The hook-leak gate cannot run without a built artifact.`,
    ).toBe(true);
    const files = listAllFilesRecursive(DIST_DIR);
    expect(
      files.length,
      `dist/ is empty after npm run build — the build produced no output, which is a different ` +
        `regression class than a hook leak. Investigate before proceeding to the hook-leak assertion.`,
    ).toBeGreaterThan(0);
  }, 30_000);

  for (const needle of FORBIDDEN_HOOK_STRINGS) {
    it(`production bundle does not contain '${needle}' (T-1-11-01 surface)`, () => {
      // If the build did not run in the previous test (SKIP_BUILD=1) AND
      // dist/ is missing, surface a clear diagnostic instead of letting
      // the recursive walk throw an obscure ENOENT.
      if (!existsSync(DIST_DIR)) {
        throw new Error(
          `dist/ missing — run \`npm run build\` first (SKIP_BUILD=1 is set but no prior build artifact exists).`,
        );
      }
      const matches = findMatchesInDist(needle);
      expect(
        matches.length,
        matches.length === 0
          ? 'unreachable'
          : `Production bundle contains '${needle}' in ${matches.length} file(s) — this would leak ` +
              `the Plan 01-11 test-hook surface to production. The Vite MODE-gate on the dynamic ` +
              `import has regressed (verify the literal-comparison branch in src/background/index.ts ` +
              `or src/offscreen/recorder.ts is still on the static-replacement path). Offending files:\n` +
              matches
                .map((m) => `  - ${m.filePath} (${m.count} occurrence${m.count === 1 ? '' : 's'})`)
                .join('\n'),
      ).toBe(0);
    });
  }
});
