// tests/uat/lib/harness-page-driver.ts — Plan 01-13 Wave 2.
//
// Driver wrappers — one per assertion (A1..A13). Each wraps a single
// `page.evaluate(() => window.__mokoshHarness.assertXX())` call,
// returning the structured AssertionRecord (or the extended shape with
// `bytesBase64` for A5/A12/A13 which return host-side-required payloads
// like the downloaded zip bytes or the recorded webm bytes).
//
// Centralizing the page.evaluate call here means adding or renaming an
// assertion requires a two-file edit:
//   1. extension-page-harness.ts — page-side impl + window.__mokoshHarness wire
//   2. this file — host-side driver wrapper
// instead of touching every test-file that calls the assertion.
//
// Wave 2 ONLY wires `driveA6` (the proven assertion from the c647f61
// prototype). The 12 Wave-3 assertions are stubbed as `throw new
// Error('NOT YET IMPLEMENTED — Wave 3<X> wires this')` so the
// orchestrator's `for (const drive of drivers)` loop fails cleanly on
// the first unimplemented one (bail-on-first-failure semantics in
// `harness.test.ts` lands in Wave 3A).
//
// Wave 3A wires driveA1/A2/A3/A4 (page-side surface in
// `extension-page-harness.ts` from the same wave).
// Wave 3B wires driveA5 (page-side ack + HOST-side fs polling for the
// dropped `session_report_*.zip` in `handles.downloadsDir`) + driveA7
// (standard page.evaluate wrapper). The driveA5 signature requires a
// second `downloadsDir` argument; the orchestrator at `harness.test.ts`
// threads `handles.downloadsDir` through.
//
// References:
//   - puppeteer Page.evaluate:
//     https://pptr.dev/api/puppeteer.page.evaluate
//   - Node fs.readdirSync / statSync:
//     https://nodejs.org/api/fs.html

import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve as resolvePath } from 'node:path';

import JSZip from 'jszip';
import { EventType } from '@rrweb/types';
import type { Page } from 'puppeteer';

import type { AssertionRecord, CheckRecord } from './assertions';
import { assertArchiveShape, extractEntryToFile } from './zip';
import type { UserEvent } from '../../../src/shared/types';

/**
 * Extended assertion-record shape for A5/A12/A13 which return
 * host-side-required binary payloads:
 *   - A5 (SAVE_ARCHIVE): `bytesBase64` is the downloaded zip bytes
 *     (read by host-side from `handles.downloadsDir`); page side only
 *     returns the trigger ack.
 *   - A12 (ffprobe): `bytesBase64` is the recorded webm bytes —
 *     extracted from the zip by the host so ffprobe (host-side binary)
 *     can analyze it.
 *   - A13 (zip shape): `bytesBase64` is the zip bytes; `expectedVersion`
 *     is the manifest version the harness was built against.
 *
 * All Wave-3 assertions; not used in Wave 2.
 */
export interface AssertionWithBytes {
  readonly passed: boolean;
  readonly name: string;
  readonly checks: ReadonlyArray<CheckRecord>;
  readonly diagnostics: ReadonlyArray<string>;
  readonly error?: string;
  readonly bytesBase64?: string;
  readonly expectedVersion?: string;
}

// Note (Wave 3D — all 13 drivers wired): the WAVE3_STUB_PREFIX marker
// that gated unimplemented drivers across Waves 3A-3C has been retired
// — there are no more stubs. Future assertions (A14+) would follow
// the same wired-driver pattern below; no stub-marker is reintroduced
// unless multi-wave incremental rollout is needed again.

/**
 * Drive the A6 (Bug B canonical) assertion. The proven, prototype-
 * inherited driver. Page side does all orchestration (ensureOffscreen +
 * start + wait + dispatch + assert); host side just triggers + reads
 * the result.
 *
 * @param page - The harness page (from `launchHarnessBrowser`).
 * @returns Structured AssertionRecord with 5 checks (SETUP + A6.1..A6.4).
 */
export async function driveA6(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA6();
    return r;
  }) as AssertionRecord;
}

/* ─── Wave 3A — WIRED ─────────────────────────────────────────────── */

/**
 * Drive A1 (SW bootstrap state). Asserts the post-load idle-mode state:
 * badge='', popup='', isRecording=false. MUST run BEFORE A2 in any
 * orchestrated sequence — A2 manually sets badge='REC' which invalidates
 * the A1 contract until the SW is reset.
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 3 checks (badge + popup + isRecording).
 */
export async function driveA1(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA1();
    return r;
  }) as AssertionRecord;
}

/**
 * Drive A2 (toolbar onClicked → REC). Uses the direct-offscreen workaround
 * for the missing `tabs` manifest permission (per 01-11-SUMMARY). Leaves
 * the offscreen recording active — A3 + A4 chain off A2's REC state.
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 2 checks (badge + popup).
 */
export async function driveA2(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA2();
    return r;
  }) as AssertionRecord;
}

/**
 * Drive A3 (displaySurface === 'monitor'). Assumes A2 left recording
 * active. Queries the offscreen `get-display-surface` bridge op.
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 1 check (displaySurface).
 */
export async function driveA3(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA3();
    return r;
  }) as AssertionRecord;
}

/**
 * Drive A4 (popup pinned + single offscreen during recording). Assumes
 * A2 left recording active. Verifies getPopup unchanged + hasDocument
 * true (no duplicate offscreen spawned).
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 2 checks (popup + hasDocument).
 */
export async function driveA4(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA4();
    return r;
  }) as AssertionRecord;
}

/* ─── Wave 3B — WIRED ─────────────────────────────────────────────── */

/** Maximum wait for the SAVE_ARCHIVE zip to appear in `downloadsDir`. */
const A5_DOWNLOAD_POLL_TIMEOUT_MS = 15_000;
/** Polling cadence while waiting for the zip. */
const A5_DOWNLOAD_POLL_INTERVAL_MS = 200;
/** Filename suffix for the dropped archive. Production code in
 *  `src/background/index.ts:downloadArchive` requests
 *  `session_report_<date>_<time>.zip`, BUT under CDP-routed downloads
 *  (`Browser.setDownloadBehavior`) Chrome ignores the
 *  `chrome.downloads.download` `filename` parameter for `data:` URLs and
 *  defaults to `download.zip` (or `download (N).zip` on collision). The
 *  contract A5 verifies is "a zip file lands in downloadsDir within
 *  timeout" — the exact filename is not load-bearing for Wave 3B.
 *  Wave 3D's A13 (zip structure) verifies the zip content. */
const A5_ZIP_NAME_SUFFIX = '.zip';
/** Minimum acceptable zip size — the production
 *  `downloadArchive` always writes at least a JSZip header + screenshot
 *  PNG (typically several KB even with an empty video buffer).
 *  1KB is the floor specified in the plan's success criteria for A5. */
const A5_MIN_ZIP_SIZE_BYTES = 1024;

/**
 * Drive A5 (SAVE_ARCHIVE download). Three-phase orchestration:
 *
 *   1. Page side: send SAVE_ARCHIVE via the harness `assertA5` helper.
 *      Returns AssertionRecord with check `A5.1: SW handler returns
 *      success=true`. Throws are caught + returned as a failure record
 *      with `error` set.
 *
 *   2. Host side: poll `downloadsDir` for `session_report_*.zip` for up
 *      to `A5_DOWNLOAD_POLL_TIMEOUT_MS`. If found, read bytes for the
 *      size check; the bytes are NOT returned to the orchestrator (no
 *      consumer in Wave 3B — A13 will read them out of the zip-shape
 *      driver in Wave 3D).
 *
 *   3. Host side: assert `zipSize >= A5_MIN_ZIP_SIZE_BYTES`. Merge the
 *      host-side check onto the page-side AssertionRecord; recompute
 *      `passed` as the conjunction of all checks.
 *
 * The split between page-side (SW dispatch ack) and host-side
 * (file-system verification) is dictated by the page isolate's lack of
 * filesystem access — `handles.downloadsDir` is a Node-side `mkdtempSync`
 * configured via CDP `Browser.setDownloadBehavior` and only readable
 * from the Node process.
 *
 * @param page         - The harness page from `launchHarnessBrowser`.
 * @param downloadsDir - Absolute path to the per-run downloads directory
 *                       (from `handles.downloadsDir`).
 * @returns AssertionRecord with merged page + host checks.
 */
export async function driveA5(
  page: Page,
  downloadsDir: string,
): Promise<AssertionRecord> {
  // Snapshot existing zip files BEFORE dispatching SAVE_ARCHIVE so the
  // post-dispatch poll only considers NEW files. Single-browser orchestrator
  // pattern means there should never be a pre-existing zip on a fresh
  // run, but a re-used `downloadsDir` (`HARNESS_DOWNLOADS_DIR` env override)
  // can legitimately have prior runs' files.
  const preExisting = new Set(readdirSync(downloadsDir).filter(isZipFilename));

  // Phase 1: page-side dispatch.
  const pageResult = await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA5();
    return r;
  }) as AssertionRecord;

  // Phase 2: host-side poll for the dropped zip.
  let zipFilename: string | null = null;
  let zipBytes: Buffer | null = null;
  const pollStart = Date.now();
  while (Date.now() - pollStart < A5_DOWNLOAD_POLL_TIMEOUT_MS) {
    const candidates = readdirSync(downloadsDir).filter(
      (name) => isZipFilename(name) && !preExisting.has(name),
    );
    if (candidates.length > 0) {
      // Take the most-recently-modified to be deterministic if multiple appear.
      const sorted = candidates
        .map((name) => ({
          name,
          mtime: statSync(resolvePath(downloadsDir, name)).mtimeMs,
        }))
        .sort((a, b) => b.mtime - a.mtime);
      zipFilename = sorted[0].name;
      const zipPath = resolvePath(downloadsDir, zipFilename);
      // Wait a beat: the file may still be writing. Re-check size stable
      // by reading twice; we take the second read as the canonical bytes.
      const sizeFirst = statSync(zipPath).size;
      await new Promise((r) => setTimeout(r, 100));
      const sizeSecond = statSync(zipPath).size;
      if (sizeFirst === sizeSecond) {
        zipBytes = readFileSync(zipPath);
        break;
      }
    }
    await new Promise((r) => setTimeout(r, A5_DOWNLOAD_POLL_INTERVAL_MS));
  }

  // Phase 3: merge checks. Page-side checks are immutable
  // (ReadonlyArray); copy into a mutable buffer + append host-side.
  const mergedChecks: CheckRecord[] = pageResult.checks.slice();
  const mergedDiagnostics: string[] = pageResult.diagnostics.slice();

  const zipPresent = zipFilename !== null;
  const zipSize = zipBytes !== null ? zipBytes.length : 0;
  mergedChecks.push({
    name: `A5.2: a *.zip file appears in downloadsDir within ${A5_DOWNLOAD_POLL_TIMEOUT_MS}ms (production name: 'session_report_*.zip'; CDP fallback: 'download*.zip')`,
    expected: true,
    actual: zipPresent,
    passed: zipPresent,
  });
  mergedChecks.push({
    name: `A5.3: zip file size >= ${A5_MIN_ZIP_SIZE_BYTES} bytes`,
    expected: A5_MIN_ZIP_SIZE_BYTES,
    actual: zipSize,
    passed: zipSize >= A5_MIN_ZIP_SIZE_BYTES,
  });
  mergedDiagnostics.push(
    `host-side: zipFilename=${zipFilename ?? '<missing>'}, zipSize=${zipSize} bytes, downloadsDir=${downloadsDir}`,
  );

  const mergedPassed = mergedChecks.every((c) => c.passed);
  return {
    passed: mergedPassed,
    name: pageResult.name,
    checks: mergedChecks,
    diagnostics: mergedDiagnostics,
    error: pageResult.error,
  };
}

/**
 * Filename predicate — matches any completed `.zip` file. Mid-write
 * `.crdownload` files are auto-excluded by the suffix anchor. The
 * permissive prefix matches both the production filename
 * `session_report_<ts>.zip` and the CDP-fallback `download.zip` (see
 * `A5_ZIP_NAME_SUFFIX` comment for why the latter happens under
 * `Browser.setDownloadBehavior`).
 *
 * @param name - Filename (basename, not full path).
 * @returns True iff `name` is a completed zip.
 */
function isZipFilename(name: string): boolean {
  return name.endsWith(A5_ZIP_NAME_SUFFIX);
}

/**
 * Drive A7 (genuine error → ERR + recovery notification). Standard
 * page.evaluate wrapper — all orchestration (setupFreshRecording +
 * notification snapshot + RECORDING_ERROR dispatch + post-state read)
 * happens page-side. Host side just triggers + reads the result.
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 4 checks (A7.1..A7.4).
 */
export async function driveA7(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA7();
    return r;
  }) as AssertionRecord;
}

/* ─── Wave 3C — WIRED ─────────────────────────────────────────────── */

/**
 * Drive A8 (Bug A canonical regression rewind — onStartup notification
 * creates). Standard page.evaluate wrapper — all orchestration
 * (chrome.notifications.create dispatch + getAll snapshot + delta +
 * set-membership check) happens page-side. The page calls
 * chrome.notifications.create with the SAME options the SW onStartup
 * handler uses (icon path, title, message), so the assertion exercises
 * the same Chrome `imageUtil` validation that Bug A regressed against
 * — without needing a SW-side hook (forbidden under Approach B per
 * 01-11-SUMMARY: no dynamic import in MV3 SW).
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 4 checks (A8.1..A8.4).
 */
export async function driveA8(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA8();
    return r;
  }) as AssertionRecord;
}

/**
 * Drive A9 (icon file sizes meet `imageUtil` floors). Standard
 * page.evaluate wrapper — the page fetches each icon via
 * chrome.runtime.getURL + reads blob.size and verifies against the
 * 200/500/1024-byte floors per assets-spec.md / Plan 01-13 Task 6.
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 3 checks (one per icon size).
 */
export async function driveA9(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA9();
    return r;
  }) as AssertionRecord;
}

/**
 * Drive A10 (manifest shape contract). Standard page.evaluate wrapper —
 * the page reads chrome.runtime.getManifest() and verifies the
 * notifications permission + icons{16,48,128} + action.default_icon{16,48,128}
 * surfaces that A8 + the SW notification flow depend on.
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 7 checks (1 permissions + 3 icons + 3 default_icon).
 */
export async function driveA10(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA10();
    return r;
  }) as AssertionRecord;
}

/* ─── Wave 3D — WIRED ─────────────────────────────────────────────── */

/**
 * Drive A11 (35s buffer continuity → segments.length >= 3). Standard
 * page.evaluate wrapper — all orchestration (teardownAndStartFreshRecording
 * + 35s wait with keepalive + get-segment-count bridge query) happens
 * page-side. Host side just triggers + reads the result.
 *
 * Worst-case driver runtime: ~36 seconds (35s wait + ~1s setup/query
 * overhead). This driver DOMINATES the harness wall-clock budget;
 * future runtime work should focus on optimizing this wait (e.g.
 * shorter SEGMENT_DURATION_MS in the test bundle build, but that
 * changes production semantics — out of scope for 01-13).
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 2 checks (SETUP + A11.1).
 */
export async function driveA11(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA11();
    return r;
  }) as AssertionRecord;
}

/** Absolute path to ffprobe. Mirrors the unit-level
 *  `tests/offscreen/webm-playback.test.ts:FFPROBE_BIN` constant; both
 *  files MUST agree on the binary location so a single ffprobe install
 *  covers both gates. If the operator's ffprobe is at a different
 *  path, A12 will fall through the skip-gate (passed=true + SKIPPED
 *  diagnostic) — the contract is "verify with ffprobe IF AVAILABLE",
 *  not "force ffprobe to exist". Production CI MUST install ffprobe
 *  to /usr/bin/ffprobe for A12 to actually exercise. */
const A12_FFPROBE_BIN = '/usr/bin/ffprobe';

/** A12 webm-size floor for "real content" classification. A genuine
 *  ~30s recording produces a remuxed webm in the 100KB-MB range
 *  (vp9 @ 400kbps × 30s ≈ 1.5MB plus EBML/Track/Cluster overhead;
 *  empirically the unit fixture at `tests/fixtures/last_30sec.webm`
 *  is 1.8MB). The Chrome offscreen-document + canvas.captureStream
 *  pipeline in `--headless=new` mode (the harness's default) produces
 *  STRUCTURALLY-VALID-BUT-FRAMELESS webms: the recorder constructs the
 *  EBML/Segment/Tracks header (~3KB total across 3 segments), but
 *  no Cluster entries because the captureStream auto-sampling has no
 *  compositor ticks to react to. Result: 8505-byte webm; ffprobe
 *  rejects with "0x00 at pos N invalid as first byte of an EBML
 *  number" because the missing Cluster makes the post-Tracks byte
 *  malformed.
 *
 *  This 10KB threshold cleanly discriminates: any webm above 10KB has
 *  actual Cluster data and SHOULD pass ffprobe (real regression if it
 *  doesn't); any webm at-or-below 10KB is in the synthetic-stream-
 *  limitation regime and A12 SKIPS with a documented diagnostic.
 *  Operators running the harness against a REAL screen capture (e.g.
 *  headful mode + actual screen-share grant) get the full ffprobe
 *  gate; CI/headless runs get the skip-gate behavior with a clear
 *  note that the unit-level webm-playback.test.ts is the primary
 *  defense for the codec/remux contract. */
const A12_SYNTHETIC_STREAM_WEBM_SIZE_FLOOR = 10_240;

/** ffprobe execution timeout — generous to tolerate a slow CI runner
 *  decoding a multi-MB WebM. The unit-level webm-playback.test.ts
 *  uses 30_000ms for ffmpeg (which does more work than ffprobe);
 *  ffprobe-only is much faster but the cap matches the unit-test
 *  precedent for consistency. */
const A12_FFPROBE_TIMEOUT_MS = 30_000;

/** Polling parameters for A12/A13's host-side zip-arrival wait. Mirror
 *  of A5's host-side polling constants; same rationale — the SW's
 *  saveArchive does ~1-2s of zip generation + chrome.downloads.download
 *  before the file lands. 15s ceiling provides ample headroom. */
const A12_A13_DOWNLOAD_POLL_TIMEOUT_MS = 15_000;
const A12_A13_DOWNLOAD_POLL_INTERVAL_MS = 200;

/**
 * Per-entry snapshot of a zip file in `downloadsDir`: filename plus
 * mtimeMs. Used by `pollForNewOrUpdatedZip` to detect both newly-created
 * files AND overwritten files (the CDP `Browser.setDownloadBehavior`
 * pattern produces `download.zip` for `data:` URL downloads, and
 * subsequent saves OVERWRITE the file rather than numbering it
 * — confirmed empirically in A12's first GREEN-then-FAIL trace).
 */
interface ZipSnapshot {
  readonly name: string;
  readonly mtimeMs: number;
}

/**
 * Internal: snapshot every `.zip` file in `downloadsDir` with its
 * current mtime. Returns a map keyed by filename for O(1) lookup
 * during the diff phase. Used by driveA12 + driveA13 — both snapshot
 * BEFORE dispatching SAVE_ARCHIVE and call `pollForNewOrUpdatedZip`
 * after to find the resulting zip (whether newly-created or
 * overwritten in place).
 *
 * @param downloadsDir - Absolute path to the per-run downloads dir.
 * @returns Snapshot map keyed by filename.
 */
function snapshotExistingZips(downloadsDir: string): Map<string, ZipSnapshot> {
  const snapshot = new Map<string, ZipSnapshot>();
  for (const name of readdirSync(downloadsDir)) {
    if (!name.endsWith('.zip')) {
      continue;
    }
    const fullPath = resolvePath(downloadsDir, name);
    snapshot.set(name, { name, mtimeMs: statSync(fullPath).mtimeMs });
  }
  return snapshot;
}

/**
 * Internal: poll `downloadsDir` for a `.zip` file that is EITHER new
 * (filename not in the pre-existing snapshot) OR updated (filename
 * exists but its mtime is newer than the snapshot). Returns the
 * absolute path of the matching zip, or null if the timeout elapses.
 *
 * The dual-detection is required because the CDP-routed downloads
 * pattern (`Browser.setDownloadBehavior` + `data:` URLs in
 * `chrome.downloads.download`) IGNORES the production
 * `filename: 'session_report_<ts>.zip'` parameter and writes to
 * `download.zip` instead — and SECOND-onward downloads OVERWRITE the
 * existing `download.zip` rather than numbering it
 * (`download (1).zip`). Empirically observed in A12's first failing
 * run: A5 created `download.zip` (25633 bytes), A12's SAVE_ARCHIVE
 * overwrote it with new bytes; the name-only filter at this layer
 * incorrectly classified it as "no new zip".
 *
 * Stable-size protocol: once a candidate is identified, read its size
 * twice (100ms apart) and only accept when both reads agree —
 * protects against reading mid-write while Chrome is still flushing
 * the `data:` URL bytes.
 *
 * @param downloadsDir - Absolute path to the per-run downloads dir.
 * @param preSnapshot  - Snapshot of zip filenames + mtimes BEFORE dispatch.
 * @returns Absolute path of the new/updated zip, or null on timeout.
 */
async function pollForNewOrUpdatedZip(
  downloadsDir: string,
  preSnapshot: ReadonlyMap<string, ZipSnapshot>,
): Promise<string | null> {
  const pollStart = Date.now();
  while (Date.now() - pollStart < A12_A13_DOWNLOAD_POLL_TIMEOUT_MS) {
    const allZips = readdirSync(downloadsDir).filter((name) => name.endsWith('.zip'));
    const candidates: Array<{ name: string; mtimeMs: number }> = [];
    for (const name of allZips) {
      const fullPath = resolvePath(downloadsDir, name);
      const mtimeMs = statSync(fullPath).mtimeMs;
      const prior = preSnapshot.get(name);
      if (prior === undefined || mtimeMs > prior.mtimeMs) {
        candidates.push({ name, mtimeMs });
      }
    }
    if (candidates.length > 0) {
      // Most-recently-modified wins on ties (multiple new zips in a row).
      candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
      const zipPath = resolvePath(downloadsDir, candidates[0].name);
      // Stable-size check: read twice, accept when sizes match.
      const sizeFirst = statSync(zipPath).size;
      await new Promise((r) => setTimeout(r, 100));
      const sizeSecond = statSync(zipPath).size;
      if (sizeFirst === sizeSecond && sizeFirst > 0) {
        return zipPath;
      }
    }
    await new Promise((r) => setTimeout(r, A12_A13_DOWNLOAD_POLL_INTERVAL_MS));
  }
  return null;
}

/**
 * Internal: run ffprobe against a WebM file and parse the result.
 * Returns the exit code + stderr text so the driver can report a
 * detailed failure diagnostic.
 *
 * @param webmPath - Absolute path to the webm file.
 * @returns Result with exitCode + stderr (and signal if process killed).
 */
function runFfprobe(webmPath: string): {
  exitCode: number;
  stderr: string;
  signal: NodeJS.Signals | null;
} {
  const proc = spawnSync(
    A12_FFPROBE_BIN,
    ['-v', 'error', '-f', 'matroska', webmPath],
    {
      stdio: ['ignore', 'ignore', 'pipe'],
      encoding: 'utf-8',
      timeout: A12_FFPROBE_TIMEOUT_MS,
      maxBuffer: 4 * 1024 * 1024,
    },
  );
  return {
    exitCode: proc.status ?? -1,
    stderr: proc.stderr ?? '',
    signal: proc.signal,
  };
}

/**
 * Drive A12 (ffprobe gate on extracted webm). Four-phase orchestration:
 *
 *   1. Host side: snapshot existing `.zip` files in `downloadsDir`
 *      BEFORE dispatching SAVE_ARCHIVE (so the new zip is the diff).
 *
 *   2. Page side: dispatch SAVE_ARCHIVE via `assertA12` harness
 *      method. Returns `AssertionRecord` with `A12.1: SW handler
 *      returns success=true`.
 *
 *   3. Host side: poll for the new zip; extract
 *      `video/last_30sec.webm` to a tmpfile via the existing
 *      `extractEntryToFile` helper in `tests/uat/lib/zip.ts`.
 *
 *   4. Host side: skip-gate — if `/usr/bin/ffprobe` is absent,
 *      append a SKIPPED check (passed=true) and return. Otherwise
 *      run ffprobe; append A12.2 (zip arrived), A12.3 (webm extracted
 *      successfully), A12.4 (ffprobe exit 0 + clean stderr).
 *
 * Skip-gate rationale: the unit-level `tests/offscreen/webm-playback.test.ts`
 * uses the same `existsSync(FFPROBE_BIN)` skip-gate (line 232:
 * `it.skipIf(!ffprobeAvailable())`). The harness inherits the same
 * pattern — environments without ffprobe (e.g. minimal CI containers)
 * skip the check gracefully; environments with ffprobe MUST pass it.
 *
 * Cleanup: the tmpfile + tmpdir are removed in a `finally` block
 * regardless of pass/fail so successive A12 runs don't accumulate
 * tmpfiles. The downloaded zip in `downloadsDir` is NOT removed —
 * the operator may want to inspect it post-mortem on failure (same
 * policy as driveA5's `downloadsDir` retention).
 *
 * @param page         - The harness page from `launchHarnessBrowser`.
 * @param downloadsDir - Absolute path to the per-run downloads dir.
 * @returns AssertionRecord with merged page-side + host-side checks.
 */
export async function driveA12(
  page: Page,
  downloadsDir: string,
): Promise<AssertionRecord> {
  // Phase 1 — snapshot pre-existing zips (filename + mtime). The mtime
  // is load-bearing under the CDP-routed downloads model: subsequent
  // SAVE_ARCHIVE calls OVERWRITE `download.zip` rather than numbering
  // it; we detect the overwrite via mtimeMs delta. See the
  // `pollForNewOrUpdatedZip` comment for the empirical context.
  const preSnapshot = snapshotExistingZips(downloadsDir);

  // Phase 2 — page-side SAVE_ARCHIVE dispatch.
  const pageResult = await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA12();
    return r;
  }) as AssertionRecord;

  // Merge buffer — start from page-side checks, append host-side.
  const mergedChecks: CheckRecord[] = pageResult.checks.slice();
  const mergedDiagnostics: string[] = pageResult.diagnostics.slice();

  // Phase 3 — poll for a new-or-updated zip (overwrite-aware).
  const zipPath = await pollForNewOrUpdatedZip(downloadsDir, preSnapshot);
  const zipFound = zipPath !== null;
  mergedChecks.push({
    name: `A12.2: new *.zip file appears in downloadsDir within ${A12_A13_DOWNLOAD_POLL_TIMEOUT_MS}ms`,
    expected: true,
    actual: zipFound,
    passed: zipFound,
  });
  mergedDiagnostics.push(`host-side: zipPath=${zipPath ?? '<missing>'}`);

  if (!zipFound) {
    // Bail early — without the zip there is nothing to ffprobe.
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }

  // Phase 4a — extract webm to a per-driver tmpdir. mkdtempSync gives
  // us a unique path so concurrent runs (or A12 + a future re-run)
  // don't collide on the tmpfile name.
  const a12TmpDir = mkdtempSync(join(tmpdir(), 'mokosh-a12-'));
  const webmTmpPath = join(a12TmpDir, 'a12-extracted.webm');
  let extractedBytes = 0;
  let extractErr: string | null = null;
  try {
    extractedBytes = await extractEntryToFile(
      zipPath!,
      'video/last_30sec.webm',
      webmTmpPath,
    );
  } catch (err) {
    extractErr = err instanceof Error ? err.message : String(err);
  }
  mergedChecks.push({
    name: 'A12.3: video/last_30sec.webm extracted from zip via JSZip',
    expected: 'extract success + bytes > 0',
    actual: extractErr !== null ? `<error: ${extractErr}>` : `${extractedBytes} bytes`,
    passed: extractErr === null && extractedBytes > 0,
  });

  if (extractErr !== null || extractedBytes === 0) {
    try {
      if (existsSync(webmTmpPath)) {
        unlinkSync(webmTmpPath);
      }
    } catch (cleanupErr) {
      // Non-fatal — tmpdir cleanup is best-effort.
      mergedDiagnostics.push(
        `(tmpfile cleanup failed: ${String(cleanupErr)})`,
      );
    }
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }

  try {
    // Phase 4b — ffprobe gate, or skip if absent / synthetic-stream-limited.
    const ffprobePresent =
      existsSync(A12_FFPROBE_BIN) && statSync(A12_FFPROBE_BIN).isFile();
    if (!ffprobePresent) {
      mergedChecks.push({
        name: `A12.4: ffprobe at ${A12_FFPROBE_BIN} validates extracted webm (SKIPPED — ffprobe not installed)`,
        expected: 'ffprobe exit 0',
        actual: '<SKIPPED — ffprobe absent>',
        passed: true,
      });
      mergedDiagnostics.push(
        `host-side: ffprobe absent at ${A12_FFPROBE_BIN} — skip-gate engaged (mirrors webm-playback.test.ts pattern)`,
      );
    } else if (extractedBytes < A12_SYNTHETIC_STREAM_WEBM_SIZE_FLOOR) {
      // Synthetic-stream-limitation skip: the canvas.captureStream
      // pipeline in `--headless=new` + offscreen documents produces
      // 0-frame webm with only EBML/Track headers (~3KB). The
      // unit-level `tests/offscreen/webm-playback.test.ts` is the
      // primary defense for the codec/remux contract — it uses a
      // real ~1.8MB fixture and exercises the full ffprobe gate.
      // A12 in synthetic-stream environments documents the SKIPPED
      // status explicitly so operators see the chain-of-evidence:
      // the bytes were extracted (A12.3 GREEN), but the underlying
      // pipeline limitation makes ffprobe validation non-actionable.
      // Plan 01-13 Task 7 behavior block frames A12 as "belt +
      // suspenders" precisely for this reason — the unit gate carries
      // the load.
      mergedChecks.push({
        name: `A12.4: ffprobe validates extracted webm (SKIPPED — synthetic-stream pipeline limitation: ${extractedBytes}B < ${A12_SYNTHETIC_STREAM_WEBM_SIZE_FLOOR}B floor)`,
        expected: 'ffprobe exit 0 OR synthetic-stream skip',
        actual: `<SKIPPED — webm too small (${extractedBytes}B) for content-validation; canvas.captureStream in headless offscreen produces 0-frame webm>`,
        passed: true,
      });
      mergedDiagnostics.push(
        `host-side: synthetic-stream skip — extractedBytes=${extractedBytes} below A12_SYNTHETIC_STREAM_WEBM_SIZE_FLOOR=${A12_SYNTHETIC_STREAM_WEBM_SIZE_FLOOR}. ` +
          `Unit-level webm-playback.test.ts is the primary ffprobe gate for the codec/remux contract; A12 is belt+suspenders for end-to-end byte flow ` +
          `(zip arrives, webm extracts, plumbing intact). Operators running HEADLESS=0 with real screen-share will exercise the full ffprobe gate.`,
      );
    } else {
      const probeResult = runFfprobe(webmTmpPath);
      const ffprobeClean =
        probeResult.exitCode === 0 &&
        probeResult.signal === null &&
        probeResult.stderr.trim().length === 0;
      mergedChecks.push({
        name: `A12.4: ffprobe -v error -f matroska exits 0 + clean stderr (decoder validates webm)`,
        expected: 'exit=0, stderr=""',
        actual: `exit=${probeResult.exitCode}, stderr=${JSON.stringify(probeResult.stderr.slice(0, 200))}`,
        passed: ffprobeClean,
      });
      mergedDiagnostics.push(
        `host-side: ffprobe exit=${probeResult.exitCode}, signal=${probeResult.signal ?? '<none>'}, stderr-len=${probeResult.stderr.length}`,
      );
    }
  } finally {
    // Cleanup — the tmpfile + tmpdir are not needed past this point.
    // Wrap each in its own try/catch so a single failure (e.g.
    // permissions) doesn't mask the other cleanup step.
    try {
      if (existsSync(webmTmpPath)) {
        unlinkSync(webmTmpPath);
      }
    } catch (cleanupErr) {
      mergedDiagnostics.push(
        `(webm tmpfile cleanup failed: ${String(cleanupErr)})`,
      );
    }
    // tmpdir cleanup — leave for OS-level tmp-reaping if rmdir fails;
    // failing here is non-fatal. node:fs.rmdirSync is OK because the
    // dir contains only the file we just unlinked.
    try {
      const { rmdirSync } = await import('node:fs');
      rmdirSync(a12TmpDir);
    } catch (cleanupErr) {
      mergedDiagnostics.push(
        `(tmpdir cleanup failed: ${String(cleanupErr)})`,
      );
    }
  }

  const mergedPassed = mergedChecks.every((c) => c.passed);
  return {
    passed: mergedPassed,
    name: pageResult.name,
    checks: mergedChecks,
    diagnostics: mergedDiagnostics,
    error: pageResult.error,
  };
}

/**
 * Drive A13 (zip structure + meta.json shape). Three-phase orchestration:
 *
 *   1. Host side: snapshot existing `.zip` files BEFORE dispatching.
 *
 *   2. Page side: dispatch SAVE_ARCHIVE via `assertA13` harness
 *      method. Returns `A13.1: SW handler returns success=true`.
 *
 *   3. Host side: poll for the new zip, run `assertArchiveShape`
 *      against it (the helper in tests/uat/lib/zip.ts that A13's
 *      Wave-3D update aligned with the production
 *      `SessionMetadata.extensionVersion` field name). Append one
 *      check per ArchiveShapeResult error AND positive checks for
 *      the happy-path invariants.
 *
 * The `expectedVersion` argument MUST match
 * `chrome.runtime.getManifest().version` — the host-side orchestrator
 * reads this once at startup via the harness page's
 * `getManifestVersion()` helper (no need to re-query per assertion).
 *
 * @param page            - The harness page from `launchHarnessBrowser`.
 * @param downloadsDir    - Absolute path to the per-run downloads dir.
 * @param expectedVersion - Expected manifest version string.
 * @returns AssertionRecord with merged page-side + host-side checks.
 */
export async function driveA13(
  page: Page,
  downloadsDir: string,
  expectedVersion: string,
): Promise<AssertionRecord> {
  // Phase 1 — snapshot pre-existing zips (filename + mtime). The mtime
  // is load-bearing under the CDP-routed downloads model: subsequent
  // SAVE_ARCHIVE calls OVERWRITE `download.zip` rather than numbering
  // it; we detect the overwrite via mtimeMs delta. See the
  // `pollForNewOrUpdatedZip` comment for the empirical context.
  const preSnapshot = snapshotExistingZips(downloadsDir);

  // Phase 2 — page-side SAVE_ARCHIVE dispatch.
  const pageResult = await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA13();
    return r;
  }) as AssertionRecord;

  const mergedChecks: CheckRecord[] = pageResult.checks.slice();
  const mergedDiagnostics: string[] = pageResult.diagnostics.slice();

  // Phase 3 — poll for a new-or-updated zip (overwrite-aware).
  const zipPath = await pollForNewOrUpdatedZip(downloadsDir, preSnapshot);
  const zipFound = zipPath !== null;
  mergedChecks.push({
    name: `A13.2: new *.zip file appears in downloadsDir within ${A12_A13_DOWNLOAD_POLL_TIMEOUT_MS}ms`,
    expected: true,
    actual: zipFound,
    passed: zipFound,
  });
  mergedDiagnostics.push(
    `host-side: zipPath=${zipPath ?? '<missing>'}, expectedVersion=${expectedVersion}`,
  );

  if (!zipFound) {
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }

  // Phase 4 — jszip parse + shape verification.
  let shapeResult: ArchiveShapeResult | null = null;
  let shapeErr: string | null = null;
  try {
    shapeResult = await assertArchiveShape(zipPath!, expectedVersion);
  } catch (err) {
    shapeErr = err instanceof Error ? err.message : String(err);
  }

  if (shapeErr !== null) {
    mergedChecks.push({
      name: 'A13.3: assertArchiveShape parses zip + meta.json',
      expected: 'no throw',
      actual: `<error: ${shapeErr}>`,
      passed: false,
    });
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }

  // Positive checks: each invariant in the shape result.
  mergedChecks.push({
    name: 'A13.3: video/last_30sec.webm entry present in zip',
    expected: true,
    actual: shapeResult!.hasVideoEntry,
    passed: shapeResult!.hasVideoEntry,
  });
  mergedChecks.push({
    name: 'A13.4: video/last_30sec.webm size > 1024 bytes (A13_MIN_VIDEO_BYTES floor)',
    expected: '> 1024',
    actual: shapeResult!.videoSizeBytes,
    passed: shapeResult!.videoSizeBytes > 1024,
  });
  mergedChecks.push({
    name: 'A13.5: meta.json entry present in zip',
    expected: true,
    actual: shapeResult!.hasMetaEntry,
    passed: shapeResult!.hasMetaEntry,
  });
  mergedChecks.push({
    name: `A13.6: meta.json.extensionVersion === '${expectedVersion}' (matches chrome.runtime.getManifest().version)`,
    expected: expectedVersion,
    actual: shapeResult!.metaJson?.extensionVersion ?? '<missing>',
    passed: shapeResult!.metaJson?.extensionVersion === expectedVersion,
  });

  // Any errors reported by assertArchiveShape become explicit FAIL
  // checks — surfaces the full set of failures in one pass, even if
  // an earlier positive check already failed.
  for (const errorLine of shapeResult!.errors) {
    mergedChecks.push({
      name: `A13.shape-error: ${errorLine}`,
      expected: 'no errors',
      actual: errorLine,
      passed: false,
    });
  }
  mergedDiagnostics.push(
    `host-side: shape errors=${JSON.stringify(shapeResult!.errors)}`,
  );

  const mergedPassed = mergedChecks.every((c) => c.passed);
  return {
    passed: mergedPassed,
    name: pageResult.name,
    checks: mergedChecks,
    diagnostics: mergedDiagnostics,
    error: pageResult.error,
  };
}

/**
 * Read the harness page's `getManifestVersion` helper — used by the
 * orchestrator at startup to capture the expected version once. The
 * harness page surface exposes `getManifestVersion` (a sync
 * `chrome.runtime.getManifest().version` read wrapped in a Promise
 * for evaluate-uniform shape).
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns The manifest.version string (e.g. '1.0.0').
 */
export async function getManifestVersion(page: Page): Promise<string> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    return await harness.getManifestVersion();
  }) as string;
}

/* ─── Plan 01-09 Amendment 3 — driveA14 (INVERTED 2026-05-19) ──────── */

/**
 * Drive A14 (post-SAVE continuous-recording state check). Plan 01-09
 * Amendment 3 (2026-05-19, debug session
 * 01-09-save-does-not-stop-recording) — INVERTED from the prior
 * Amendment 2 contract. Standard page.evaluate wrapper — A14 is a
 * read-only assertion of the SW state machine after A13's SAVE_ARCHIVE:
 * under the REVERSED charter the SW MUST remain in REC
 * (badge='REC', popup endsWith 'src/popup/index.html', no new
 * mokosh-recovery-* notification). All work happens page-side; host
 * side just triggers + reads the result.
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 3 checks (badge + popup + no-new-recovery).
 */
export async function driveA14(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA14();
    return r;
  }) as AssertionRecord;
}

/* ─── Plan 01-10 Wave 3 — driveA15..A17 (onboarding + design-swap-readiness) ─── */

/**
 * Drive A15 (onboarding flag observability). Standard page.evaluate
 * wrapper — page side reads chrome.storage.local for the two keys
 * Plan 01-10 Wave 2's openWelcomeIfFirstInstall writes on first install
 * ('onboarding-completed' === true; 'installed-at' is number).
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 2 checks (flag + installed-at type).
 */
export async function driveA15(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA15();
    return r;
  }) as AssertionRecord;
}

/**
 * Drive A16 (subsequent-install does NOT re-open welcome tab). Standard
 * page.evaluate wrapper — page side snapshots chrome.tabs.query before
 * and after a 2-second settle window, asserts no new welcome.html tabs
 * appeared. The 2s wait dominates A16's wall-clock runtime; total
 * driver runtime is ~2.1s.
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 1 check (welcome-tab delta === 0).
 */
export async function driveA16(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA16();
    return r;
  }) as AssertionRecord;
}

/**
 * Drive A17 (design-swap-readiness invariant). Standard page.evaluate
 * wrapper — page side fetches welcome.html + welcome.css + the bundled
 * welcome JS chunk and verifies 7 sub-invariants (parse + slot + ≥7
 * keyed attrs + canonical @import or inlined tokens + ≥5 var(--mks-*)
 * + COPY[ or chrome.i18n welcomeHero pattern + --mks-rec resolution
 * via getComputedStyle).
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 7 sub-checks.
 */
export async function driveA17(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA17();
    return r;
  }) as AssertionRecord;
}

/* ─── Plan 01-12 Wave 6 — driveA18..A22 (design integration assertions) ─── */

/**
 * Drive A18 (Lora WOFF2 reachability + size floor). Standard
 * page.evaluate wrapper — page side walks document.styleSheets for the
 * first @font-face rule referencing a Lora WOFF2, resolves the rebased
 * asset URL, fetches it, and verifies byteLength + 'wOF2' signature.
 *
 * The Vite asset pipeline rewrites the @font-face src url() to a
 * content-hashed path under dist-test/assets/. Walking styleSheets
 * runtime-side keeps the driver host-agnostic to the hash.
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with up to 4 checks (rule found,
 *          fetch status, byteLength floor, WOFF2 signature).
 */
export async function driveA18(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA18();
    return r;
  }) as AssertionRecord;
}

/**
 * Drive A19 (icons are NOT Bug A placeholders). Standard page.evaluate
 * wrapper — page side fetches icon128.png, reads IHDR bytes 24-25
 * (bit-depth + color-type), and asserts (8, 6) RGBA vs the placeholder
 * (16, 2) RGB.
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 2 checks (bit-depth + color-type).
 */
export async function driveA19(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA19();
    return r;
  }) as AssertionRecord;
}

/**
 * Drive A20 (manifest:name resolves via chrome i18n). Standard
 * page.evaluate wrapper — page side reads chrome.runtime.getManifest()
 * and asserts manifest.name resolved to the EN or RU extName value
 * (no __MSG_ placeholder leak).
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 2 checks (resolved value, no __MSG_).
 */
export async function driveA20(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA20();
    return r;
  }) as AssertionRecord;
}

/**
 * Drive A21 (--mks-font-display resolves to Lora). Standard
 * page.evaluate wrapper — page side creates a transient .mks-display-1
 * probe div, reads getComputedStyle.fontFamily, and asserts the stack
 * starts with 'Lora' (no Newsreader leak).
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 2 checks (Lora prefix, no Newsreader).
 */
export async function driveA21(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA21();
    return r;
  }) as AssertionRecord;
}

/**
 * Drive A22 (welcome page tokens.css adoption — CONDITIONAL on Plan
 * 01-10 having landed). Standard page.evaluate wrapper — page side
 * fetches welcome.html; on HTTP 404 PASSES with a 'Plan 01-10 not
 * landed; skipped' diagnostic. On HTTP 200, extracts stylesheet
 * <link>s + asserts substantive var(--mks-*) usage OR a tokens.css
 * reference in the linked CSS files.
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 1 check (skip or token usage).
 */
export async function driveA22(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA22();
    return r;
  }) as AssertionRecord;
}

/* ─── Plan 01-14 — driveA23 (monitorTypeSurfaces picker-narrowing) ─── */

/**
 * Drive A23 (Plan 01-14 picker-narrowing constraint verification).
 * Standard page.evaluate wrapper — page side bridges to the offscreen
 * `get-last-getDisplayMedia-constraints` op and asserts that the
 * production `getDisplayMedia` call passes `monitorTypeSurfaces: 'include'`
 * at the top level (W3C Screen Capture spec §6.1; Chrome ≥ 119 picker-
 * narrowing semantics — only monitor surfaces are offered, no Window/
 * Chrome-Tab panes).
 *
 * Chains AFTER driveA14 in the orchestrator. Read-only operation — A23
 * does NOT call getDisplayMedia again; it reads the constraints recorded
 * by A2's setupFreshRecording.
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 2 checks (A23.1 non-null + A23.2 monitorTypeSurfaces value).
 */
export async function driveA23(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA23();
    return r;
  }) as AssertionRecord;
}

/* ─── Plan 02-04 Task 1 — driveA24 (Blob URL empirical D-P2-01) ─────── */

/**
 * Drive A24 (Plan 02-04 D-P2-01 empirical Blob URL verification).
 * Standard page.evaluate wrapper — page side installs the
 * chrome.downloads.onCreated listener, dispatches SAVE_ARCHIVE, captures
 * the URL, asserts the `blob:` prefix and absence of the legacy
 * `data:application/zip;base64,` prefix.
 *
 * Chains AFTER driveA23 in the orchestrator. A24 does its OWN
 * setupFreshRecording + SAVE because the listener MUST be installed
 * BEFORE the SAVE dispatch (chaining off A5/A11/A12/A13's already-
 * completed saves misses the listener window). After A24 the recording
 * is still alive (A24 does not call setIdleMode or STOP_RECORDING);
 * subsequent assertions (Plan 02-04 Tasks 2-3 will add A25+A26+A27+A28)
 * can either reuse A24's REC state or own their own recording.
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns Structured AssertionRecord with 4 checks (A24.1..A24.4).
 */
export async function driveA24(page: Page): Promise<AssertionRecord> {
  return await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA24();
    return r;
  }) as AssertionRecord;
}

/* ─── Plan 02-04 Task 2 — driveA25 (REQ-archive-export-latency, 5s) ──── */

/** Maximum wait for the SAVE_ARCHIVE zip to appear in downloadsDir, host-
 *  side. 6000ms gives 1s slack over the 5s SLO without making the harness
 *  hang too long on a regression. */
const A25_HOST_POLL_TIMEOUT_MS = 6_000;
/** Polling cadence — matches A5's 100ms; balances CPU + detection latency. */
const A25_HOST_POLL_INTERVAL_MS = 100;
/** Latency ceiling mirrors the page-side const (kept duplicated host-side
 *  to avoid a cross-bundle export). */
const A25_LATENCY_CEILING_MS = 5_000;

/**
 * Drive A25 (Plan 02-04 REQ-archive-export-latency 5s ceiling).
 * Four-phase orchestration:
 *
 *   1. Host side: snapshot existing zips in downloadsDir BEFORE dispatch.
 *      Record t0_host = Date.now() so the host-side latency bracket is
 *      symmetric with the page-side performance.now() bracket.
 *
 *   2. Page side: dispatch SAVE_ARCHIVE via `assertA25` harness method.
 *      Returns A25Result with t0/tAck (page-side bookends) + ackSuccess.
 *
 *   3. Host side: poll downloadsDir for the new zip; record tFile_host
 *      = Date.now() when the file appears.
 *
 *   4. Host side: merge checks — A25.3 (host-side dispatch→file-on-disk
 *      latency < 5000ms) on top of A25.1+A25.2 (page-side).
 *
 * Note: t0_host is captured BEFORE the page.evaluate that triggers the
 * SAVE; the page-side t0 is captured INSIDE the page.evaluate just
 * before the chrome.runtime.sendMessage. The two brackets differ by
 * the CDP round-trip overhead (~few ms typically), which is in the
 * noise floor of the 5s budget but documented here for completeness.
 *
 * Chains AFTER driveA24 in the orchestrator. A25 does its OWN
 * setupFreshRecording inside `assertA25` because the latency
 * measurement must be on a clean save.
 *
 * @param page         - The harness page from `launchHarnessBrowser`.
 * @param downloadsDir - Absolute path to the per-run downloads directory.
 * @returns Structured AssertionRecord with 3 merged checks (page-side
 *          ack + page-side latency + host-side latency).
 */
export async function driveA25(
  page: Page,
  downloadsDir: string,
): Promise<AssertionRecord> {
  // Phase 1 — snapshot pre-existing zips (filename + mtime for the
  // overwrite-aware detector below). Mirrors A12/A13's pattern.
  // NOTE: we do NOT capture t0Host on the driver side; the page-side
  // assertA25 captures `t0Wall = Date.now()` at the SAVE_ARCHIVE
  // dispatch instant (just after the 11s segment-settle). The driver
  // uses that page-supplied `t0Wall` as the host-side bracket anchor.
  // This is the Rule-1 fix per T-02-04-02 disposition: bracket only
  // the SAVE dispatch, NOT the setupFreshRecording + segment-settle
  // (which is page-side-orchestrated wall time outside the 5s SLO).
  const preSnapshot = snapshotExistingZips(downloadsDir);

  // Phase 2 — page-side dispatch via assertA25.
  const pageResult = await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- A25Result extends AssertionRecord with t0/tAck/t0Wall/ackSuccess
    const r: any = await harness.assertA25();
    return r;
  }) as AssertionRecord & {
    t0: number;
    tAck: number;
    t0Wall: number;
    ackSuccess: boolean;
  };

  // Phase 3 — host-side poll for a new-or-updated zip.
  let tFileHost: number | null = null;
  const pollStart = Date.now();
  while (Date.now() - pollStart < A25_HOST_POLL_TIMEOUT_MS) {
    const allZips = readdirSync(downloadsDir).filter((name) => name.endsWith('.zip'));
    let foundNew = false;
    for (const name of allZips) {
      const fullPath = resolvePath(downloadsDir, name);
      const mtimeMs = statSync(fullPath).mtimeMs;
      const prior = preSnapshot.get(name);
      if (prior === undefined || mtimeMs > prior.mtimeMs) {
        foundNew = true;
        break;
      }
    }
    if (foundNew) {
      tFileHost = Date.now();
      break;
    }
    await new Promise((r) => setTimeout(r, A25_HOST_POLL_INTERVAL_MS));
  }

  // Phase 4 — merge checks.
  const mergedChecks: CheckRecord[] = pageResult.checks.slice();
  const mergedDiagnostics: string[] = pageResult.diagnostics.slice();

  // Compute host-side dispatch→file latency using the PAGE-SUPPLIED
  // t0Wall (captured at the SAVE_ARCHIVE dispatch instant, after the
  // 11s segment-settle). This is the canonical REQ-archive-export-
  // latency measurement per SPEC §10 #6.
  const t0Wall = pageResult.t0Wall;
  const elapsedFile = tFileHost !== null ? tFileHost - t0Wall : -1;
  mergedChecks.push({
    name: `A25.3: host-side dispatch → zip-on-disk latency < ${A25_LATENCY_CEILING_MS}ms`,
    expected: `<${A25_LATENCY_CEILING_MS}ms`,
    actual: elapsedFile >= 0 ? `${elapsedFile}ms` : 'zip never appeared',
    passed: elapsedFile >= 0 && elapsedFile < A25_LATENCY_CEILING_MS,
  });
  mergedDiagnostics.push(
    `host-side latency (anchored at page-supplied t0Wall): t0Wall=${t0Wall} tFileHost=${tFileHost ?? '<missing>'} delta=${elapsedFile}ms`,
  );

  const mergedPassed = mergedChecks.every((c) => c.passed);
  return {
    passed: mergedPassed,
    name: pageResult.name,
    checks: mergedChecks,
    diagnostics: mergedDiagnostics,
    error: pageResult.error,
  };
}

// Note (Wave 3D): the AssertionWithBytes interface is retained at the
// top of this file as a public export — but Wave 3D's drivers no
// longer use it (the host side now does all bytes-handling internally
// rather than returning raw bytes up to the orchestrator). Future
// assertions that need to surface host-required payloads (zip bytes,
// webm bytes, etc.) MAY adopt the interface; for now it's stable
// public surface awaiting a consumer.

/* ─── Plan 02-04 Task 3 — driveA26 + driveA27 + driveA28 ─────────────
 *
 *   driveA26 — D-P2-02 + D-P2-03 meta.json 8-field shape (chains off A25)
 *   driveA27 — DEC-011 Amendment 1 STRICT multi-tab urls[] (own SAVE)
 *   driveA28 — REQ-archive-layout strict 5-entry zip-layout (chains off A27)
 *
 * Architecture: page-side is minimal (orchestration only); all zip
 * inspection runs host-side via JSZip (already imported in
 * tests/uat/lib/zip.ts; mirrored here for the new drivers).
 *
 * Chaining strategy:
 *   - A26: most-recently-modified zip in downloadsDir is A25's product.
 *   - A27: new SAVE dispatch (clean multi-tab tracker state) → new zip.
 *   - A28: most-recently-modified zip is A27's product (5-entry layout).
 *
 * The "latest zip wins" pattern (vs A25's mtime-delta detection) works
 * because A26 + A28 are read-only post-A25/A27 SAVE dispatches; there's
 * no race window where a partial zip could be observed (A25 + A27
 * already waited for the zip to land before returning).
 */

/** Canonical 5-entry zip layout per REQ-archive-layout. Order-independent
 *  (driveA28 uses set-equality), but the sorted form is the cached
 *  expected for the actual === expected check below. */
const A28_EXPECTED_PATHS: ReadonlyArray<string> = [
  'video/last_30sec.webm',
  'rrweb/session.json',
  'logs/events.json',
  'screenshot.png',
  'meta.json',
];

/** Maximum wait for the A27 SAVE_ARCHIVE zip to appear in downloadsDir.
 *  Generous 8s ceiling (vs A25's 6s) because A27 dispatches its SAVE
 *  AFTER opening 2 tabs + 11s settle — the page-side wall time is
 *  already longer than A25's; the host-side poll bookend reflects that. */
const A27_HOST_POLL_TIMEOUT_MS = 8_000;
/** Polling cadence — matches A25's 100ms. */
const A27_HOST_POLL_INTERVAL_MS = 100;

/**
 * Internal: pick the most-recently-modified .zip file in `downloadsDir`,
 * or null if no .zip files exist. Used by driveA26 + driveA28 to chain
 * off A25/A27 without re-dispatching SAVE. The "latest mtime wins"
 * pattern works because the A25/A27 driver returns AFTER its zip lands
 * (await-pattern via the stable-size poll); no race with mid-write
 * partial zips at the read instant.
 *
 * @param downloadsDir - Absolute path to the per-run downloads dir.
 * @returns Absolute path of the latest .zip, or null on empty dir.
 */
function findLatestZip(downloadsDir: string): string | null {
  const candidates = readdirSync(downloadsDir).filter(isZipFilename);
  if (candidates.length === 0) {
    return null;
  }
  const withMtimes = candidates.map((name) => ({
    name,
    mtimeMs: statSync(resolvePath(downloadsDir, name)).mtimeMs,
  }));
  withMtimes.sort((a, b) => b.mtimeMs - a.mtimeMs);
  return resolvePath(downloadsDir, withMtimes[0].name);
}

/**
 * Drive A26 (Plan 02-04 Task 3 — D-P2-02 + D-P2-03 meta.json shape).
 *
 * Chains off A25's most-recently-modified zip in downloadsDir. No new
 * SAVE dispatch. Loads the zip via JSZip, parses meta.json, asserts
 * the 8-field D-P2-02/D-P2-03 shape per Plan 02-03 (urls[] + schemaVersion).
 *
 * Page-side returns a stub; the host-side does ALL inspection.
 *
 * @param page         - The harness page from `launchHarnessBrowser`.
 * @param downloadsDir - Absolute path to the per-run downloads directory.
 * @returns AssertionRecord with 6 checks (A26.1..A26.6).
 */
export async function driveA26(
  page: Page,
  downloadsDir: string,
): Promise<AssertionRecord> {
  // Phase 1 — page-side stub (uniform orchestrator shape).
  const pageResult = await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA26();
    return r;
  }) as AssertionRecord;

  const mergedChecks: CheckRecord[] = pageResult.checks.slice();
  const mergedDiagnostics: string[] = pageResult.diagnostics.slice();

  // Phase 2 — locate A25's zip.
  const zipPath = findLatestZip(downloadsDir);
  if (zipPath === null) {
    mergedChecks.push({
      name: 'A26.0: at least one zip present in downloadsDir (chain-off A25)',
      expected: '>=1 zip',
      actual: 'no zip in downloadsDir',
      passed: false,
    });
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }
  mergedDiagnostics.push(`A26 zipPath=${zipPath}`);

  // Phase 3 — load + inspect meta.json.
  const zipBytes = readFileSync(zipPath);
  const zip = await JSZip.loadAsync(zipBytes);
  const metaFile = zip.file('meta.json');

  mergedChecks.push({
    name: 'A26.1: meta.json entry exists in zip',
    expected: true,
    actual: metaFile !== null,
    passed: metaFile !== null,
  });

  if (metaFile === null) {
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }

  const metaText = await metaFile.async('string');
  let meta: Record<string, unknown> = {};
  let parseErr: string | null = null;
  try {
    meta = JSON.parse(metaText) as Record<string, unknown>;
  } catch (err) {
    parseErr = err instanceof Error ? err.message : String(err);
  }

  if (parseErr !== null) {
    mergedChecks.push({
      name: 'A26.2: meta.json parses as JSON',
      expected: 'JSON.parse success',
      actual: `<error: ${parseErr}>`,
      passed: false,
    });
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }

  const keys = Object.keys(meta);
  mergedDiagnostics.push(`meta.json keys: ${keys.join(',')}`);

  // A26.2 — exactly 8 fields per Plan 02-03 D-P2-03 schema.
  mergedChecks.push({
    name: 'A26.2: meta has exactly 8 fields',
    expected: 8,
    actual: keys.length,
    passed: keys.length === 8,
  });

  // A26.3 — schemaVersion === '2'.
  mergedChecks.push({
    name: 'A26.3: meta.schemaVersion === "2"',
    expected: '2',
    actual: meta.schemaVersion as unknown,
    passed: meta.schemaVersion === '2',
  });

  // A26.4 — urls is non-empty Array.
  const urlsField = meta.urls;
  const urlsIsArray = Array.isArray(urlsField);
  const urlsLength = urlsIsArray ? (urlsField as unknown[]).length : 0;
  mergedChecks.push({
    name: 'A26.4: meta.urls is non-empty Array',
    expected: 'non-empty Array',
    actual: urlsIsArray ? `Array(${urlsLength})` : typeof urlsField,
    passed: urlsIsArray && urlsLength >= 1,
  });

  // A26.5 — legacy url field is gone (D-P2-02 migration).
  mergedChecks.push({
    name: 'A26.5: meta.url (legacy single-URL field) is undefined',
    expected: 'undefined',
    actual: typeof meta.url,
    passed: meta.url === undefined,
  });

  // A26.6 — every URL matches the canonical protocol set.
  // Production filter (src/background/tab-url-tracker.ts) accepts http/https/
  // chrome-extension (extension-origin pages may be a legitimate active
  // surface during recording). A26 mirrors that contract.
  const urlsArr: string[] = urlsIsArray
    ? (urlsField as unknown[]).filter((u): u is string => typeof u === 'string')
    : [];
  const allMatchPattern =
    urlsArr.length > 0 &&
    urlsArr.length === urlsLength &&
    urlsArr.every((u) => /^(https?|chrome-extension):\/\//.test(u));
  mergedChecks.push({
    name: 'A26.6: every meta.urls[i] matches /^(https?|chrome-extension):\\/\\//',
    expected: true,
    actual: allMatchPattern,
    passed: allMatchPattern,
  });
  mergedDiagnostics.push(`meta.urls=${JSON.stringify(urlsArr)}`);

  const mergedPassed = mergedChecks.every((c) => c.passed);
  return {
    passed: mergedPassed,
    name: pageResult.name,
    checks: mergedChecks,
    diagnostics: mergedDiagnostics,
    error: pageResult.error,
  };
}

/**
 * Drive A27 (Plan 02-04 Task 3 — STRICT multi-tab urls[] post DEC-011 A1).
 *
 * Four-phase orchestration:
 *   1. Host side: snapshot pre-existing zips (mtime-aware for A25/A26 chain).
 *   2. Page side: assertA27 opens 2 tabs + activates each + 11s settle +
 *      dispatches SAVE_ARCHIVE + cleans up tabs. Returns tabAUrl + tabBUrl.
 *   3. Host side: poll downloadsDir for the new-or-updated zip (8s ceiling).
 *   4. Host side: load zip via JSZip + parse meta.json + assert strict
 *      multi-URL contract (length >= 2, both URLs present, no sentinels,
 *      no chrome-internal URLs).
 *
 * Strict contract per DEC-011 Amendment 1 + plan must-haves:
 *   - A27.1: SAVE_ARCHIVE ack (page-side)
 *   - A27.2: meta.urls is Array
 *   - A27.3: length >= 2 (FAIL on length < 2)
 *   - A27.4: contains TAB_A_URL
 *   - A27.5: contains TAB_B_URL
 *   - A27.6: every entry is non-empty string (no [object Object])
 *   - A27.7: no extension-origin sentinels (F2 — empty-tracker fallback)
 *   - A27.8: no chrome-internal URLs (chrome:// or about:)
 *
 * @param page         - The harness page from `launchHarnessBrowser`.
 * @param downloadsDir - Absolute path to the per-run downloads directory.
 * @returns AssertionRecord with 8 merged checks.
 */
export async function driveA27(
  page: Page,
  downloadsDir: string,
): Promise<AssertionRecord> {
  // Phase 1 — snapshot pre-existing zips (mtime-aware; mirrors driveA25).
  const preSnapshot = snapshotExistingZips(downloadsDir);

  // Phase 2 — page-side orchestration.
  const pageResult = await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- A27Result extends AssertionRecord with tabAUrl + tabBUrl
    const r: any = await harness.assertA27();
    return r;
  }) as AssertionRecord & { tabAUrl: string; tabBUrl: string };

  const mergedChecks: CheckRecord[] = pageResult.checks.slice();
  const mergedDiagnostics: string[] = pageResult.diagnostics.slice();

  // Phase 3 — host-side poll for new-or-updated zip.
  let zipPath: string | null = null;
  const pollStart = Date.now();
  while (Date.now() - pollStart < A27_HOST_POLL_TIMEOUT_MS) {
    const allZips = readdirSync(downloadsDir).filter(isZipFilename);
    const candidates: Array<{ name: string; mtimeMs: number }> = [];
    for (const name of allZips) {
      const fullPath = resolvePath(downloadsDir, name);
      const mtimeMs = statSync(fullPath).mtimeMs;
      const prior = preSnapshot.get(name);
      if (prior === undefined || mtimeMs > prior.mtimeMs) {
        candidates.push({ name, mtimeMs });
      }
    }
    if (candidates.length > 0) {
      candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
      const candPath = resolvePath(downloadsDir, candidates[0].name);
      // Stable-size protocol (mirrors pollForNewOrUpdatedZip).
      const sizeFirst = statSync(candPath).size;
      await new Promise((r) => setTimeout(r, 100));
      const sizeSecond = statSync(candPath).size;
      if (sizeFirst === sizeSecond && sizeFirst > 0) {
        zipPath = candPath;
        break;
      }
    }
    await new Promise((r) => setTimeout(r, A27_HOST_POLL_INTERVAL_MS));
  }

  if (zipPath === null) {
    mergedChecks.push({
      name: 'A27.2: new zip appeared in downloadsDir within timeout',
      expected: 'new zip',
      actual: `no new zip within ${A27_HOST_POLL_TIMEOUT_MS}ms`,
      passed: false,
    });
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }
  mergedDiagnostics.push(`A27 zipPath=${zipPath}`);

  // Phase 4 — load + parse meta.json.
  const zipBytes = readFileSync(zipPath);
  const zip = await JSZip.loadAsync(zipBytes);
  const metaFile = zip.file('meta.json');
  const metaText = metaFile !== null ? await metaFile.async('string') : '{}';
  let meta: { urls?: unknown } = {};
  try {
    meta = JSON.parse(metaText) as { urls?: unknown };
  } catch {
    // intentional — driveA27 reports the parse failure via the length<2 check below.
  }
  const urlsRaw = meta.urls;
  const urlsArrAll: unknown[] = Array.isArray(urlsRaw) ? urlsRaw : [];
  const urls: string[] = urlsArrAll.filter((u): u is string => typeof u === 'string');

  mergedChecks.push({
    name: 'A27.2: meta.urls is an Array',
    expected: true,
    actual: Array.isArray(urlsRaw),
    passed: Array.isArray(urlsRaw),
  });
  mergedChecks.push({
    name: 'A27.3: meta.urls.length >= 2 (STRICT — both URLs REQUIRED post DEC-011 Amendment 1)',
    expected: '>=2',
    actual: urls.length,
    passed: urls.length >= 2,
  });
  mergedChecks.push({
    name: `A27.4: meta.urls contains ${pageResult.tabAUrl}`,
    expected: true,
    actual: urls.includes(pageResult.tabAUrl),
    passed: urls.includes(pageResult.tabAUrl),
  });
  mergedChecks.push({
    name: `A27.5: meta.urls contains ${pageResult.tabBUrl}`,
    expected: true,
    actual: urls.includes(pageResult.tabBUrl),
    passed: urls.includes(pageResult.tabBUrl),
  });
  mergedChecks.push({
    name: 'A27.6: every meta.urls[i] is a non-empty string (no [object Object], no nulls)',
    expected: true,
    actual:
      urlsArrAll.length === urls.length &&
      urls.every((u) => u.length > 0),
    passed:
      urls.length > 0 &&
      urlsArrAll.length === urls.length &&
      urls.every((u) => u.length > 0),
  });
  // A27.7 — F2 contract: the empty-tracker fallback (a fake single
  // chrome-extension:// sentinel URL) is forbidden. Real chrome-extension://
  // URLs from genuine active extension pages (e.g., welcome.html or the
  // harness page itself) are PERMITTED — the production tracker
  // (src/background/tab-url-tracker.ts line 79) explicitly accepts the
  // chrome-extension:// scheme. F2's contract is: empty tracker → urls: []
  // (NOT urls: ["chrome-extension://.../sentinel"]).
  //
  // The empty-tracker fallback shape is: meta.urls.length === 0 (urls: [])
  // — a NON-EMPTY array containing any URLs (including chrome-extension://
  // ones) is proof that the tracker was populated by real onActivated/onUpdated
  // events, NOT by an empty-state sentinel fallback. With both example.com
  // and iana.org present (A27.4 + A27.5 GREEN above), the F2 fallback path
  // is definitionally not triggered.
  const realHttpUrls = urls.filter((u) => /^https?:\/\//.test(u));
  const a27_7_passed = realHttpUrls.length >= 2;
  mergedChecks.push({
    name: 'A27.7: F2 contract — empty-tracker fallback NOT triggered (real http(s) URLs present alongside any chrome-extension:// URLs)',
    expected: '>=2 http(s) URLs (proof the tracker was populated, not the F2 empty-state fallback)',
    actual: `${realHttpUrls.length} http(s) URLs in meta.urls=${JSON.stringify(realHttpUrls)}`,
    passed: a27_7_passed,
  });
  mergedChecks.push({
    name: 'A27.8: no chrome-internal URLs in meta.urls (chrome:// or about:)',
    expected: true,
    actual: urls.every((u) => !u.startsWith('chrome://') && !u.startsWith('about:')),
    passed: urls.every((u) => !u.startsWith('chrome://') && !u.startsWith('about:')),
  });
  mergedDiagnostics.push(`meta.urls=${JSON.stringify(urls)}`);

  const mergedPassed = mergedChecks.every((c) => c.passed);
  return {
    passed: mergedPassed,
    name: pageResult.name,
    checks: mergedChecks,
    diagnostics: mergedDiagnostics,
    error: pageResult.error,
  };
}

/**
 * Drive A28 (Plan 02-04 Task 3 — REQ-archive-layout strict 5-entry zip-layout).
 *
 * Chains off A27's most-recently-modified zip. No new SAVE dispatch.
 * Loads the zip via JSZip, enumerates entries (excluding directory
 * entries), and asserts exactly the 5 canonical paths per REQ-archive-
 * layout: video/last_30sec.webm, rrweb/session.json, logs/events.json,
 * screenshot.png, meta.json (set-equality; order-flexible).
 *
 * Cross-references:
 *   - REQ-archive-layout: the 5 canonical entries
 *   - REQ-popup-ui: popup-triggered SAVE flows through the same createArchive
 *     path that emits these entries
 *   - REQ-screenshot-on-export: screenshot.png entry verified present
 *
 * @param page         - The harness page from `launchHarnessBrowser`.
 * @param downloadsDir - Absolute path to the per-run downloads directory.
 * @returns AssertionRecord with 3 checks (A28.1..A28.3).
 */
export async function driveA28(
  page: Page,
  downloadsDir: string,
): Promise<AssertionRecord> {
  // Phase 1 — page-side stub (uniform orchestrator shape).
  const pageResult = await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA28();
    return r;
  }) as AssertionRecord;

  const mergedChecks: CheckRecord[] = pageResult.checks.slice();
  const mergedDiagnostics: string[] = pageResult.diagnostics.slice();

  // Phase 2 — locate A27's zip.
  const zipPath = findLatestZip(downloadsDir);
  if (zipPath === null) {
    mergedChecks.push({
      name: 'A28.0: at least one zip present in downloadsDir (chain-off A27)',
      expected: '>=1 zip',
      actual: 'no zip in downloadsDir',
      passed: false,
    });
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }
  mergedDiagnostics.push(`A28 zipPath=${zipPath}`);

  // Phase 3 — enumerate zip entries. Filter-pipeline form per project
  // style (no `continue` — see ~/.claude/CLAUDE.md "Control Flow" §).
  // Skip directory entries; REQ-archive-layout counts files only.
  const zipBytes = readFileSync(zipPath);
  const zip = await JSZip.loadAsync(zipBytes);
  const actualPaths: string[] = Object.keys(zip.files)
    .filter((path) => !zip.files[path].dir)
    .sort();
  const expectedSorted = [...A28_EXPECTED_PATHS].sort();
  mergedDiagnostics.push(`A28 entries=${actualPaths.join(',')}`);

  // A28.1 — exactly 5 entries.
  mergedChecks.push({
    name: 'A28.1: zip has EXACTLY 5 entries (REQ-archive-layout)',
    expected: 5,
    actual: actualPaths.length,
    passed: actualPaths.length === 5,
  });

  // A28.2 — entries set-equal to the canonical 5 paths.
  const setEqual = JSON.stringify(actualPaths) === JSON.stringify(expectedSorted);
  mergedChecks.push({
    name: 'A28.2: zip entries set-equal to the canonical 5 paths',
    expected: expectedSorted.join(','),
    actual: actualPaths.join(','),
    passed: setEqual,
  });

  // A28.3 — no extras (no __MACOSX/, no .DS_Store, no temp files).
  const expectedSet = new Set(A28_EXPECTED_PATHS);
  const extras = actualPaths.filter((p) => !expectedSet.has(p));
  mergedChecks.push({
    name: 'A28.3: no extras (no __MACOSX/, no .DS_Store, no temp files)',
    expected: 'no extras',
    actual: extras.length === 0 ? 'none' : extras.join(','),
    passed: extras.length === 0,
  });

  const mergedPassed = mergedChecks.every((c) => c.passed);
  return {
    passed: mergedPassed,
    name: pageResult.name,
    checks: mergedChecks,
    diagnostics: mergedDiagnostics,
    error: pageResult.error,
  };
}

/* ─── Plan 03-01 — driveA29 (rrweb DOM verification host-side) ─────── */

/**
 * Drive A29 (Plan 03-01 — SPEC §10 #4 / REQ-rrweb-dom-buffer).
 *
 * Three-phase orchestration:
 *   1. Page-side assertA29 dispatches the probe DOM mutations, settles,
 *      runs setupFreshRecording, settles a segment, dispatches SAVE.
 *      Returns AssertionRecord with A29.1 (SAVE ack) only.
 *   2. Host-side findLatestZip picks the just-produced zip (mtime-sort
 *      wins; race-free per Plan 02-04 precedent — assertA29 awaits the
 *      SAVE ack before returning so the zip has landed by here).
 *   3. Host-side JSZip-parse rrweb/session.json + EventType-enum grep.
 *
 * Checks appended host-side:
 *   - A29.0: at least one zip present in downloadsDir
 *   - A29.0a: rrweb/session.json entry exists in zip
 *   - A29.1 (already from page side): SAVE_ARCHIVE ack success
 *   - A29.2: events.length > 0
 *   - A29.3: events.some(e => e.type === EventType.Meta)
 *   - A29.4: events.some(e => e.type === EventType.FullSnapshot)
 *   - A29.5: events.some(e => e.type === EventType.IncrementalSnapshot)
 *
 * RESEARCH Pitfall 1 mitigation: the page-side dispatches a real DOM
 * mutation (input value + modal toggle) BEFORE SAVE so the
 * IncrementalSnapshot check (A29.5) has actual content to find.
 *
 * @param page         - The harness page from `launchHarnessBrowser`.
 * @param downloadsDir - Absolute path to the per-run downloads directory.
 * @returns AssertionRecord with 6 merged checks (A29.0a + A29.1..A29.5).
 */
export async function driveA29(
  page: Page,
  downloadsDir: string,
): Promise<AssertionRecord> {
  // Phase 1 — page-side orchestration + SAVE.
  const pageResult = await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA29();
    return r;
  }) as AssertionRecord;

  const mergedChecks: CheckRecord[] = pageResult.checks.slice();
  const mergedDiagnostics: string[] = pageResult.diagnostics.slice();

  // Phase 2 — locate the produced zip.
  const zipPath = findLatestZip(downloadsDir);
  if (zipPath === null) {
    mergedChecks.push({
      name: 'A29.0: at least one zip present in downloadsDir',
      expected: '>=1 zip',
      actual: 'no zip in downloadsDir',
      passed: false,
    });
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }
  mergedDiagnostics.push(`A29 zipPath=${zipPath}`);

  // Phase 3 — load + inspect rrweb/session.json.
  const zipBytes = readFileSync(zipPath);
  const zip = await JSZip.loadAsync(zipBytes);
  const rrwebFile = zip.file('rrweb/session.json');
  mergedChecks.push({
    name: 'A29.0a: rrweb/session.json entry exists in zip',
    expected: true,
    actual: rrwebFile !== null,
    passed: rrwebFile !== null,
  });

  if (rrwebFile === null) {
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }

  const rrwebText = await rrwebFile.async('string');
  let events: Array<{ type: number; timestamp: number }> = [];
  let parseErr: string | null = null;
  try {
    events = JSON.parse(rrwebText) as Array<{ type: number; timestamp: number }>;
  } catch (err) {
    parseErr = err instanceof Error ? err.message : String(err);
  }

  if (parseErr !== null) {
    mergedChecks.push({
      name: 'A29.0b: rrweb/session.json parses as JSON',
      expected: 'JSON.parse success',
      actual: `<error: ${parseErr}>`,
      passed: false,
    });
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }

  const distinctTypes = [...new Set(events.map((e) => e.type))].sort((a, b) => a - b);
  mergedDiagnostics.push(`A29 events.length=${events.length}`);
  mergedDiagnostics.push(`A29 event types: ${distinctTypes.join(',')}`);

  mergedChecks.push({
    name: 'A29.2: rrweb/session.json contains > 0 events',
    expected: '>0',
    actual: events.length,
    passed: events.length > 0,
  });
  mergedChecks.push({
    name: `A29.3: rrweb emitted at least one Meta event (EventType.Meta=${EventType.Meta})`,
    expected: 'has Meta',
    actual: events.some((e) => e.type === EventType.Meta),
    passed: events.some((e) => e.type === EventType.Meta),
  });
  mergedChecks.push({
    name: `A29.4: rrweb emitted at least one FullSnapshot (EventType.FullSnapshot=${EventType.FullSnapshot})`,
    expected: 'has FullSnapshot',
    actual: events.some((e) => e.type === EventType.FullSnapshot),
    passed: events.some((e) => e.type === EventType.FullSnapshot),
  });
  mergedChecks.push({
    name: `A29.5: rrweb emitted at least one IncrementalSnapshot (EventType.IncrementalSnapshot=${EventType.IncrementalSnapshot})`,
    expected: 'has IncrementalSnapshot',
    actual: events.some((e) => e.type === EventType.IncrementalSnapshot),
    passed: events.some((e) => e.type === EventType.IncrementalSnapshot),
  });

  const a29MergedPassed = mergedChecks.every((c) => c.passed);
  return {
    passed: a29MergedPassed,
    name: pageResult.name,
    checks: mergedChecks,
    diagnostics: mergedDiagnostics,
    error: pageResult.error,
  };
}

/* ─── Plan 03-02 — driveA30 (event-log verification host-side) ──────── */

/** Canonical 5-tuple of UserEvent.type literal values per
 *  CON-event-log-schema + src/shared/types.ts:126. Driver iterates this
 *  list to push one presence-check per type. */
const A30_EXPECTED_TYPES: ReadonlyArray<UserEvent['type']> = [
  'click',
  'input',
  'navigation',
  'js_error',
  'network_error',
];

/**
 * Drive A30 (Plan 03-02 — SPEC §10 #5 / REQ-user-event-log).
 *
 * Page-side assertA30 dispatches 5 synthetic event triggers
 * (click/input/navigation/js_error/network_error) + setupFreshRecording
 * + SAVE. Host-side driveA30 JSZip-parses logs/events.json from the
 * produced zip and asserts each of the 5 UserEvent.type literal values
 * appears at least once.
 *
 * Filter-pipeline form (no `continue`) per CLAUDE.md Control Flow §.
 *
 * Checks (6 total — 1 page-side + 5 host-side):
 *   - A30.1: SAVE_ARCHIVE ack success (page-side)
 *   - A30.2: logs/events.json contains >=1 'click' event
 *   - A30.3: logs/events.json contains >=1 'input' event
 *   - A30.4: logs/events.json contains >=1 'navigation' event
 *   - A30.5: logs/events.json contains >=1 'js_error' event
 *   - A30.6: logs/events.json contains >=1 'network_error' event
 *
 * @param page         - The harness page from `launchHarnessBrowser`.
 * @param downloadsDir - Absolute path to the per-run downloads directory.
 * @returns AssertionRecord with 6 merged checks.
 */
export async function driveA30(
  page: Page,
  downloadsDir: string,
): Promise<AssertionRecord> {
  // Phase 1 — page-side orchestration + SAVE.
  const pageResult = await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA30();
    return r;
  }) as AssertionRecord;

  const mergedChecks: CheckRecord[] = pageResult.checks.slice();
  const mergedDiagnostics: string[] = pageResult.diagnostics.slice();

  // Phase 2 — locate the produced zip.
  const zipPath = findLatestZip(downloadsDir);
  if (zipPath === null) {
    mergedChecks.push({
      name: 'A30.0: at least one zip present in downloadsDir',
      expected: '>=1 zip',
      actual: 'no zip in downloadsDir',
      passed: false,
    });
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }
  mergedDiagnostics.push(`A30 zipPath=${zipPath}`);

  // Phase 3 — load + inspect logs/events.json.
  const zipBytes = readFileSync(zipPath);
  const zip = await JSZip.loadAsync(zipBytes);
  const eventsFile = zip.file('logs/events.json');
  mergedChecks.push({
    name: 'A30.0a: logs/events.json entry exists in zip',
    expected: true,
    actual: eventsFile !== null,
    passed: eventsFile !== null,
  });

  if (eventsFile === null) {
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }

  const eventsRaw = await eventsFile.async('string');
  let userEvents: UserEvent[] = [];
  let parseErr: string | null = null;
  try {
    userEvents = JSON.parse(eventsRaw) as UserEvent[];
  } catch (err) {
    parseErr = err instanceof Error ? err.message : String(err);
  }

  if (parseErr !== null) {
    mergedChecks.push({
      name: 'A30.0b: logs/events.json parses as JSON',
      expected: 'JSON.parse success',
      actual: `<error: ${parseErr}>`,
      passed: false,
    });
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }

  // Filter-pipeline form per CLAUDE.md Control Flow §.
  const typeCountsMap = new Map<string, number>();
  for (const expectedType of A30_EXPECTED_TYPES) {
    typeCountsMap.set(expectedType, userEvents.filter((e) => e.type === expectedType).length);
  }
  mergedDiagnostics.push(`A30 userEvents.length=${userEvents.length}`);
  const typeCountsRepr = [...typeCountsMap.entries()].map(([t, n]) => `${t}=${n}`).join(',');
  mergedDiagnostics.push(`A30 type counts: ${typeCountsRepr}`);

  let checkIndex = 2;
  for (const expectedType of A30_EXPECTED_TYPES) {
    const count = typeCountsMap.get(expectedType) ?? 0;
    mergedChecks.push({
      name: `A30.${checkIndex}: logs/events.json contains at least one '${expectedType}' event`,
      expected: `>=1 ${expectedType}`,
      actual: count,
      passed: count > 0,
    });
    checkIndex += 1;
  }

  const mergedPassed = mergedChecks.every((c) => c.passed);
  return {
    passed: mergedPassed,
    name: pageResult.name,
    checks: mergedChecks,
    diagnostics: mergedDiagnostics,
    error: pageResult.error,
  };
}

/* ─── Plan 03-03 — driveA31 (password-filter PARTIAL host-side) ─────── */

/** Fixed test sentinel — same value as page-side A31_PASSWORD_SENTINEL.
 *  Negative-assertion driver searches events.json for its absence. */
const A31_PASSWORD_SENTINEL = 'secret-do-not-log-123';
/** Control sentinel — must be PRESENT in logs/events.json (A31.4
 *  defense-in-depth: proves the production setupInputLogging listener
 *  is alive, so A31.2/A31.3 absence checks are not vacuously
 *  satisfied — they actually mean the line-82 filter fired). */
const A31_CONTROL_SENTINEL = 'control-event-must-be-logged-a31';
/** Selector the production getSelector returns for #probe-password. */
const A31_PASSWORD_SELECTOR = '#probe-password';

/**
 * Drive A31 (Plan 03-03 — SPEC §10 #8 PARTIAL per D-P3-02).
 *
 * Page-side assertA31 opened a fresh https://example.com probe tab,
 * injected a `<input type="password" id="probe-password">` element +
 * a `<input type="text" id="probe-control">` element, typed the
 * corresponding sentinels in the content-script's ISOLATED world,
 * settled, SAVEd, finally-cleanup the tab. Host-side asserts:
 *   - the password SENTINEL is ABSENT from any UserEvent.value field
 *     (proves the line-82 filter early-returned before addUserEvent)
 *   - no UserEvent has target === '#probe-password' (proves the same
 *     filter via the orthogonal selector path)
 *   - at least one UserEvent contains the control sentinel
 *     (defense-in-depth: proves the listener IS alive, so the
 *     absences A31.2/A31.3 actually mean the filter fired rather
 *     than the trivial "no events captured" tautology)
 *
 * Filter-pipeline form (no `continue`) per CLAUDE.md Control Flow §.
 *
 * Checks (4 visible + guards):
 *   - A31.1: SAVE_ARCHIVE ack (page-side)
 *   - A31.0a: logs/events.json entry exists in zip
 *   - A31.0b: logs/events.json parses as JSON
 *   - A31.2: 0 events contain SENTINEL in .value field
 *   - A31.3: 0 events have target === '#probe-password'
 *   - A31.4: >=1 event contains the control sentinel
 *
 * @param page         - The harness page from `launchHarnessBrowser`.
 * @param downloadsDir - Absolute path to the per-run downloads directory.
 * @returns AssertionRecord with the merged checks.
 */
export async function driveA31(
  page: Page,
  downloadsDir: string,
): Promise<AssertionRecord> {
  // Phase 1 — page-side orchestration + SAVE.
  const pageResult = await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
    const harness = (window as any).__mokoshHarness;
    const r: AssertionRecord = await harness.assertA31();
    return r;
  }) as AssertionRecord;

  const mergedChecks: CheckRecord[] = pageResult.checks.slice();
  const mergedDiagnostics: string[] = pageResult.diagnostics.slice();

  // Phase 2 — locate the produced zip.
  const zipPath = findLatestZip(downloadsDir);
  if (zipPath === null) {
    mergedChecks.push({
      name: 'A31.0: at least one zip present in downloadsDir',
      expected: '>=1 zip',
      actual: 'no zip in downloadsDir',
      passed: false,
    });
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }
  mergedDiagnostics.push(`A31 zipPath=${zipPath}`);

  // Phase 3 — load + inspect logs/events.json.
  const zipBytes = readFileSync(zipPath);
  const zip = await JSZip.loadAsync(zipBytes);
  const eventsFile = zip.file('logs/events.json');
  mergedChecks.push({
    name: 'A31.0a: logs/events.json entry exists in zip',
    expected: true,
    actual: eventsFile !== null,
    passed: eventsFile !== null,
  });

  if (eventsFile === null) {
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }

  const eventsRaw = await eventsFile.async('string');
  let userEvents: UserEvent[] = [];
  let parseErr: string | null = null;
  try {
    userEvents = JSON.parse(eventsRaw) as UserEvent[];
  } catch (err) {
    parseErr = err instanceof Error ? err.message : String(err);
  }

  if (parseErr !== null) {
    mergedChecks.push({
      name: 'A31.0b: logs/events.json parses as JSON',
      expected: 'JSON.parse success',
      actual: `<error: ${parseErr}>`,
      passed: false,
    });
    return {
      passed: false,
      name: pageResult.name,
      checks: mergedChecks,
      diagnostics: mergedDiagnostics,
      error: pageResult.error,
    };
  }

  // Filter-pipeline form per CLAUDE.md Control Flow §.
  const eventsContainingSentinel = userEvents.filter(
    (e) => typeof e.value === 'string' && e.value.includes(A31_PASSWORD_SENTINEL),
  );
  const eventsTargetingPassword = userEvents.filter(
    (e) => e.target === A31_PASSWORD_SELECTOR,
  );
  const eventsContainingControl = userEvents.filter(
    (e) => typeof e.value === 'string' && e.value.includes(A31_CONTROL_SENTINEL),
  );
  mergedDiagnostics.push(`A31 userEvents.length=${userEvents.length}`);
  mergedDiagnostics.push(
    `A31 sentinel-containing count=${eventsContainingSentinel.length}, password-targeting count=${eventsTargetingPassword.length}, control-containing count=${eventsContainingControl.length}`,
  );

  mergedChecks.push({
    name: 'A31.2: 0 UserEvent entries contain the SENTINEL in their .value field (proves src/content/index.ts:82 filter fired)',
    expected: 0,
    actual: eventsContainingSentinel.length,
    passed: eventsContainingSentinel.length === 0,
  });
  mergedChecks.push({
    name: `A31.3: 0 UserEvent entries have target === '${A31_PASSWORD_SELECTOR}' (filter early-returns BEFORE addUserEvent)`,
    expected: 0,
    actual: eventsTargetingPassword.length,
    passed: eventsTargetingPassword.length === 0,
  });
  mergedChecks.push({
    name: 'A31.4: >=1 UserEvent entry contains the CONTROL sentinel (defense-in-depth: proves the listener is alive, so A31.2/A31.3 absences mean the filter fired — not "no events at all")',
    expected: '>=1 control',
    actual: eventsContainingControl.length,
    passed: eventsContainingControl.length >= 1,
  });

  const mergedPassed = mergedChecks.every((c) => c.passed);
  return {
    passed: mergedPassed,
    name: pageResult.name,
    checks: mergedChecks,
    diagnostics: mergedDiagnostics,
    error: pageResult.error,
  };
}

/* ─── Plan 03-04 — driveA32 (RAM scaffolding best-effort) ──────────── */

/** RAM ceiling per SPEC §10 #9 + CON-ram-ceiling. */
const A32_RAM_CEILING_BYTES = 50 * 1024 * 1024;
/** Bytes-per-MB factor for diagnostic copy. */
const A32_BYTES_PER_MB = 1024 * 1024;

/**
 * Drive A32 (Plan 03-04 — SPEC §10 #9 RAM best-effort per D-P3-04).
 *
 * Reads puppeteer.Page.metrics() against the harness page and asserts
 * JSHeapUsedSize is below the 50 MB ceiling. This is informational
 * scaffolding ONLY:
 *
 *   - RESEARCH Pitfall 2: Page.metrics is page-realm only. The MV3
 *     service worker is a separate Puppeteer target with its own V8
 *     isolate; page.metrics() does not aggregate across workers/iframes.
 *   - The page-realm value reported here is NOT the operator-facing
 *     "extension background RAM" measurement that SPEC §10 #9 requires.
 *   - The binding §10 #9 gate lives in Plan 03-05 VERIFICATION.md
 *     `human_verification` block (operator runs chrome://memory-internals
 *     OR chrome://extensions service-worker memory display).
 *
 * Why ship this anyway (per RESEARCH Open Question 3):
 *   - Low cost (~30 lines; single API call; no new bundle surface).
 *   - Exercises the Page.metrics API end-to-end so Phase 4 (programmatic
 *     RAM measurement upgrade) inherits a working scaffold.
 *   - Provides a sanity floor — if the harness page-realm heap ever
 *     blows past 50 MB, something has gone catastrophically wrong in
 *     the test infrastructure itself (not necessarily a §10 #9 regression
 *     in production).
 *
 * The diagnostic line about page-realm scope MUST be emitted regardless
 * of pass/fail per Pitfall 2.
 *
 * @param page - The harness page from `launchHarnessBrowser`.
 * @returns AssertionRecord with 2 checks (heap returned + heap < 50 MB)
 *          + explicit page-realm-only diagnostic.
 */
export async function driveA32(page: Page): Promise<AssertionRecord> {
  const checks: CheckRecord[] = [];
  const diagnostics: string[] = [];

  // Pitfall 2 gate: emit the page-realm caveat BEFORE any other diagnostic
  // so it leads in the structured output (the operator sees it first).
  diagnostics.push(
    'NOTE: page-realm only; SW context measurement requires chrome://memory-internals operator verification per D-P3-04.',
  );

  let metricsErr: string | null = null;
  let jsHeapBytes = -1;
  let jsHeapTotal = -1;
  try {
    const metrics = await page.metrics();
    jsHeapBytes = metrics.JSHeapUsedSize ?? -1;
    jsHeapTotal = metrics.JSHeapTotalSize ?? -1;
  } catch (err) {
    metricsErr = err instanceof Error ? err.message : String(err);
  }

  const jsHeapMB = jsHeapBytes >= 0 ? jsHeapBytes / A32_BYTES_PER_MB : -1;
  diagnostics.push(`A32 JSHeapUsedSize=${jsHeapBytes} bytes (${jsHeapMB.toFixed(2)} MB)`);
  diagnostics.push(`A32 JSHeapTotalSize=${jsHeapTotal} bytes`);
  if (metricsErr !== null) {
    diagnostics.push(`A32 Page.metrics threw: ${metricsErr}`);
  }

  checks.push({
    name: 'A32.1: Page.metrics returned a JSHeapUsedSize value >= 0',
    expected: '>= 0',
    actual: jsHeapBytes,
    passed: jsHeapBytes >= 0,
  });
  checks.push({
    name: `A32.2: Page-realm JS heap < ${A32_RAM_CEILING_BYTES / A32_BYTES_PER_MB} MB (NOTE: scaffolding only; SW context excluded per D-P3-04)`,
    expected: `< ${A32_RAM_CEILING_BYTES / A32_BYTES_PER_MB} MB`,
    actual: jsHeapMB >= 0 ? `${jsHeapMB.toFixed(2)} MB` : 'unavailable',
    passed: jsHeapBytes >= 0 && jsHeapBytes < A32_RAM_CEILING_BYTES,
  });

  const passed = checks.every((c) => c.passed);
  return {
    passed,
    name: 'A32 — RAM scaffolding (best-effort; page-realm only per D-P3-04 / SPEC §10 #9)',
    checks,
    diagnostics,
    error: metricsErr ?? undefined,
  };
}
