// tests/background/blob-url-download.test.ts
//
// Plan 02-01 Task 1 (RED gate, Wave 0 of Phase 2).
// Pins decision D-P2-01 (offscreen-minted Blob URL download pipeline,
// closes audit P0-6: base64 data: URL → blob: URL migration).
//
// Contract pinned here (RED today; flips GREEN after Plan 02-02):
//
//   1. `downloadArchive()` invokes `chrome.downloads.download` with a `url`
//      that starts with `blob:` and does NOT start with
//      `data:application/zip;base64,`. The wire format is the cap-free
//      Blob URL minted in the offscreen document via `URL.createObjectURL`
//      (offscreen has the DOM globals; the SW does not — DEC-006 binding).
//
//   2. A 6 MB archive completes through `downloadArchive()` in under 5 s
//      wall-clock AND emits a blob: URL. The current base64 path is
//      bounded by Chrome's ~2 MB data-URL cap in the real runtime; the
//      vi.fn() chrome.downloads.download stub can't model the rejection
//      itself, so this test's gating signal is the WIRE-FORMAT prefix at
//      6 MB scale (latency check is necessary but not sufficient — the
//      prefix guard converts a fast spurious pass into a wire-format
//      failure under current HEAD).
//
//   3. `URL.revokeObjectURL(url)` is scheduled for the same URL passed to
//      `chrome.downloads.download` once `chrome.downloads.onChanged`
//      reports `state === 'complete'`. Lifecycle hygiene; without it the
//      offscreen page leaks one URL handle per saved archive.
//
// Test architecture: load `src/background/index.ts`, dispatch a
// `SAVE_ARCHIVE` message, resolve the in-flight `REQUEST_BUFFER` with
// synthetic single/multi-segment BUFFERs so `createArchive()` produces a
// real archive Blob and `downloadArchive()` runs. The single
// chrome.downloads.download call's argv0 is then introspected. The
// chrome.downloads.onChanged.addListener stub captures the listener so
// Test 3 can fire the synthetic 'complete' event back at it.
//
// remuxSegments mocking (Test 2): the production muxer rejects
// monotonicity-breaking input shapes, which makes synthetic 6 MB stress
// tests fragile. Test 2 short-circuits remuxSegments via vi.mock to
// return a synthetic 6 MB Blob directly — preserving createArchive's
// post-remux JSZip + downloadArchive code path as the SUT.
//
// Environment: vitest default `node` env. Node 24's globals expose
// URL.createObjectURL + URL.revokeObjectURL + performance, so no jsdom
// override is required (verified at land time).
//
// Skip discipline: NONE — bare `it()` blocks, no skip modifiers.
// Plan 02-01 T-02-01-01 mitigation: this file contains zero
// describe-skip or it-skip method calls.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve as resolvePath } from 'node:path';

// ──────────────────────────────────────────────────────────────────────
// Constants
// ──────────────────────────────────────────────────────────────────────
const LATENCY_BUDGET_MS = 5_000;
const SIX_MEGABYTES = 6 * 1024 * 1024;

const here = dirname(fileURLToPath(import.meta.url));
const FIXTURE_PATH = resolvePath(
  here, '..', '..', 'tests', 'fixtures', 'raw-3ebml-concat.webm',
);
// Per-EBML offsets (Plan 01-08 Task 2 byte-level probe; mirrored from
// tests/background/webm-remux.test.ts so the 3-slice segmentation is
// muxer-safe — each slice is a self-contained ~10 s WebM with its own
// EBML + Segment + Cluster tree).
const SEG1_START = 0;
const SEG2_START = 509038;
const SEG3_START = 970967;

// ──────────────────────────────────────────────────────────────────────
// Chrome stub shape (extended from save-archive-does-not-stop-recording
// pattern with chrome.downloads.onChanged listener capture).
// ──────────────────────────────────────────────────────────────────────

interface PortStub {
  name: string;
  sender?: { id?: string };
  postMessage: ReturnType<typeof vi.fn>;
  onMessage: {
    addListener: ReturnType<typeof vi.fn>;
    removeListener: ReturnType<typeof vi.fn>;
    _listeners: Array<(msg: unknown) => void>;
  };
  onDisconnect: {
    addListener: (fn: () => void) => void;
    _listeners: Array<() => void>;
  };
  disconnect: ReturnType<typeof vi.fn>;
}

function makePortStub(name = 'video-keepalive'): PortStub {
  const port: PortStub = {
    name,
    sender: { id: 'ext-id-test' },
    postMessage: vi.fn(),
    onMessage: {
      addListener: vi.fn(),
      removeListener: vi.fn(),
      _listeners: [],
    },
    onDisconnect: {
      addListener: (fn: () => void) => {
        port.onDisconnect._listeners.push(fn);
      },
      _listeners: [],
    },
    disconnect: vi.fn(),
  };
  port.onMessage.addListener.mockImplementation(
    (fn: (msg: unknown) => void) => {
      port.onMessage._listeners.push(fn);
    },
  );
  port.onMessage.removeListener.mockImplementation(
    (fn: (msg: unknown) => void) => {
      const idx = port.onMessage._listeners.indexOf(fn);
      if (idx >= 0) {
        port.onMessage._listeners.splice(idx, 1);
      }
    },
  );
  return port;
}

interface OnConnectCallback { (port: PortStub): void; }
interface OnMessageCallback {
  (msg: unknown, sender: { id?: string }, sendResponse: (r: unknown) => void): boolean;
}
interface OnClickedCallback { (info?: unknown): void | Promise<void>; }
interface DownloadDelta {
  id: number;
  state?: { current: 'in_progress' | 'complete' | 'interrupted' };
}
interface DownloadsOnChangedCallback { (delta: DownloadDelta): void; }

interface BgChromeStub {
  runtime: {
    id: string;
    getURL: (p: string) => string;
    getManifest: () => { version: string };
    sendMessage: ReturnType<typeof vi.fn>;
    onMessage: { addListener: ReturnType<typeof vi.fn>; _callbacks: OnMessageCallback[] };
    onConnect: { addListener: (cb: OnConnectCallback) => void; _callbacks: OnConnectCallback[] };
    onInstalled: { addListener: ReturnType<typeof vi.fn> };
    onStartup: { addListener: (cb: () => void) => void; _callbacks: Array<() => void> };
  };
  offscreen: {
    hasDocument?: () => Promise<boolean>;
    createDocument: ReturnType<typeof vi.fn>;
    Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' };
  };
  tabs: {
    query: ReturnType<typeof vi.fn>;
    sendMessage: ReturnType<typeof vi.fn>;
    captureVisibleTab: ReturnType<typeof vi.fn>;
  };
  downloads: {
    download: ReturnType<typeof vi.fn>;
    onChanged: {
      addListener: ReturnType<typeof vi.fn>;
      _callbacks: DownloadsOnChangedCallback[];
    };
  };
  action: {
    onClicked: { addListener: (cb: OnClickedCallback) => void; _callbacks: OnClickedCallback[] };
    setPopup: ReturnType<typeof vi.fn>;
    setBadgeText: ReturnType<typeof vi.fn>;
    setBadgeBackgroundColor: ReturnType<typeof vi.fn>;
    setTitle: ReturnType<typeof vi.fn>;
  };
  notifications: {
    create: ReturnType<typeof vi.fn>;
    clear: ReturnType<typeof vi.fn>;
    onClicked: {
      addListener: ReturnType<typeof vi.fn>;
      _callbacks: Array<(id: string) => void>;
    };
  };
}

interface GlobalWithBgChrome {
  chrome?: BgChromeStub;
  indexedDB?: { deleteDatabase: ReturnType<typeof vi.fn> };
  fetch?: typeof fetch;
}

function buildBgStub(): BgChromeStub {
  const stub: BgChromeStub = {
    runtime: {
      id: 'ext-id-test',
      getURL: (p) => `chrome-extension://ext-id-test/${p}`,
      getManifest: () => ({ version: '1.0.0' }),
      sendMessage: vi.fn().mockResolvedValue(undefined),
      onMessage: { addListener: vi.fn(), _callbacks: [] },
      onConnect: {
        addListener: (cb) => stub.runtime.onConnect._callbacks.push(cb),
        _callbacks: [],
      },
      onInstalled: { addListener: vi.fn() },
      onStartup: {
        addListener: (cb) => stub.runtime.onStartup._callbacks.push(cb),
        _callbacks: [],
      },
    },
    offscreen: {
      hasDocument: async () => false,
      createDocument: vi.fn().mockResolvedValue(undefined),
      Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' },
    },
    tabs: {
      query: vi.fn().mockResolvedValue([
        { id: 1, url: 'https://example.com', windowId: 100 },
      ]),
      sendMessage: vi.fn().mockResolvedValue({ events: [], userEvents: [] }),
      captureVisibleTab: vi.fn().mockResolvedValue(
        'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
      ),
    },
    downloads: {
      download: vi.fn().mockResolvedValue(42),
      onChanged: {
        addListener: vi.fn(),
        _callbacks: [],
      },
    },
    action: {
      onClicked: {
        addListener: (cb) => stub.action.onClicked._callbacks.push(cb),
        _callbacks: [],
      },
      setPopup: vi.fn(),
      setBadgeText: vi.fn(),
      setBadgeBackgroundColor: vi.fn(),
      setTitle: vi.fn(),
    },
    notifications: {
      create: vi.fn(),
      clear: vi.fn(),
      onClicked: {
        addListener: vi.fn(),
        _callbacks: [],
      },
    },
  };
  stub.runtime.onMessage.addListener.mockImplementation((cb: OnMessageCallback) => {
    stub.runtime.onMessage._callbacks.push(cb);
  });
  stub.downloads.onChanged.addListener.mockImplementation(
    (cb: DownloadsOnChangedCallback) => {
      stub.downloads.onChanged._callbacks.push(cb);
    },
  );
  stub.notifications.onClicked.addListener.mockImplementation(
    (cb: (id: string) => void) => {
      stub.notifications.onClicked._callbacks.push(cb);
    },
  );
  return stub;
}

async function stubFetch(_url: string): Promise<Response> {
  const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
  return {
    blob: async () => new Blob([png], { type: 'image/png' }),
  } as unknown as Response;
}

// ──────────────────────────────────────────────────────────────────────
// Minimal FileReader polyfill — JSZip's Blob ingestion path calls
// `new FileReader().readAsArrayBuffer(blob)` to materialise the bytes.
// Node 24 ships native Blob (so JSZip's `support.blob` returns true) but
// does NOT ship FileReader, so the ingestion throws "Can't read the
// data of '<entry>'". The polyfill below delegates to Blob.arrayBuffer()
// (which IS available in Node 24) and dispatches onload synchronously
// on the microtask tick.
//
// This polyfill is scoped to the test process (set on globalThis); it
// does not leak into production builds because tests run under vitest
// in a separate Node process.
// ──────────────────────────────────────────────────────────────────────
class FileReaderPolyfill {
  result: ArrayBuffer | string | null = null;
  onload: ((ev: { target: FileReaderPolyfill }) => void) | null = null;
  onerror: ((ev: { target: FileReaderPolyfill; error: unknown }) => void) | null = null;
  readyState = 0;

  readAsArrayBuffer(blob: Blob): void {
    blob.arrayBuffer()
      .then((ab) => {
        this.result = ab;
        this.readyState = 2;
        if (this.onload) this.onload({ target: this });
      })
      .catch((err) => {
        this.readyState = 2;
        if (this.onerror) this.onerror({ target: this, error: err });
      });
  }
}

function installFileReaderPolyfill(): void {
  if (typeof (globalThis as { FileReader?: unknown }).FileReader === 'undefined') {
    (globalThis as { FileReader?: unknown }).FileReader = FileReaderPolyfill;
  }
}

// ──────────────────────────────────────────────────────────────────────
// Three-slice fixture segments (each is a self-contained ~10 s WebM —
// muxer-safe). Mirrors splitFixtureIntoSegments() in webm-remux.test.ts.
// ──────────────────────────────────────────────────────────────────────
function buildThreeSliceSegmentsBase64(): Array<{ data: string; type: string; timestamp: number }> {
  const buf = readFileSync(FIXTURE_PATH);
  const slices = [
    buf.subarray(SEG1_START, SEG2_START),
    buf.subarray(SEG2_START, SEG3_START),
    buf.subarray(SEG3_START, buf.length),
  ];
  return slices.map((slice, i) => ({
    data: Buffer.from(slice).toString('base64'),
    type: 'video/webm;codecs=vp9',
    timestamp: 1_000_000 + i * 10_000,
  }));
}

/**
 * Drive a SAVE_ARCHIVE flow to completion. Resolves the in-flight
 * REQUEST_BUFFER with the three-slice fixture segments.
 *
 * @param stub - Wired chrome stub.
 * @param port - Connected offscreen port.
 * @returns The chrome.downloads.download arg0 payload, or undefined if
 *          no call landed within the drain window.
 */
async function runSaveAndCaptureDownloadArg(
  stub: BgChromeStub,
  port: PortStub,
): Promise<{ url: string; filename: string; saveAs?: boolean } | undefined> {
  const segments = buildThreeSliceSegmentsBase64();

  let buffered = false;
  const tryFireBuffer = () => {
    if (buffered) return;
    const reqCalls = port.postMessage.mock.calls.filter(
      (c: unknown[]) =>
        typeof c[0] === 'object' &&
        c[0] !== null &&
        (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER',
    );
    if (reqCalls.length === 0) return;
    const reqMsg = reqCalls[0][0] as { requestId?: string };
    buffered = true;
    port.onMessage._listeners.forEach((fn) =>
      fn({
        type: 'BUFFER',
        requestId: reqMsg.requestId,
        segments,
      }),
    );
  };

  // Plan 02-02 (D-P2-01): simulate the offscreen-side CREATE_DOWNLOAD_URL
  // handler. The SW posts CREATE_DOWNLOAD_URL with the archive bytes as
  // base64; the real offscreen mints a blob:URL via URL.createObjectURL
  // (src/offscreen/recorder.ts:handleCreateDownloadUrl). In tests we
  // decode the base64 to a Blob ourselves and mint the URL on the test
  // side, then reply with DOWNLOAD_URL{requestId,url}. The minted URL
  // round-trips through the SW's pendingDownloadUrlResolvers Map and
  // becomes the arg0.url passed to chrome.downloads.download. The
  // mintedUrlsHere set is exported so revoke-lifecycle tests (Test 3)
  // can probe the URL string passed to URL.revokeObjectURL.
  const mintedUrlsHere = new Set<string>();
  const tryFireDownloadUrl = () => {
    const mintCalls = port.postMessage.mock.calls.filter(
      (c: unknown[]) =>
        typeof c[0] === 'object' &&
        c[0] !== null &&
        (c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL',
    );
    for (const call of mintCalls) {
      const mintMsg = call[0] as {
        requestId?: string;
        dataBase64?: string;
        mimeType?: string;
      };
      const requestId = mintMsg.requestId;
      if (typeof requestId !== 'string' || requestId.length === 0) continue;
      if (mintedUrlsHere.has(requestId)) continue;
      mintedUrlsHere.add(requestId);
      const dataBase64 = mintMsg.dataBase64 ?? '';
      const mimeType = mintMsg.mimeType ?? 'application/zip';
      let url = '';
      try {
        // Node 24 ships URL.createObjectURL + Blob as globals. The
        // resulting URL has the `blob:nodedata:<uuid>` shape, which
        // satisfies the `url.startsWith('blob:')` polarity guard.
        const binary = atob(dataBase64);
        const bytes = new Uint8Array(binary.length);
        for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
        const blob = new Blob([bytes], { type: mimeType });
        url = URL.createObjectURL(blob);
      } catch {
        url = '';
      }
      port.onMessage._listeners.forEach((fn) =>
        fn({ type: 'DOWNLOAD_URL', requestId, url }),
      );
    }
  };

  const onMsg = stub.runtime.onMessage._callbacks[0];
  onMsg(
    { type: 'SAVE_ARCHIVE' },
    { id: 'ext-id-test' },
    () => undefined,
  );

  const DRAIN_ITERATIONS = 1_500;
  for (let i = 0; i < DRAIN_ITERATIONS; i++) {
    tryFireBuffer();
    tryFireDownloadUrl();
    if (stub.downloads.download.mock.calls.length > 0) break;
    await new Promise<void>((resolve) => setTimeout(resolve, 5));
  }

  const calls = stub.downloads.download.mock.calls;
  if (calls.length === 0) return undefined;
  return calls[0][0] as { url: string; filename: string; saveAs?: boolean };
}

/**
 * Load SW module, wire stubs, connect the offscreen port, and put the SW
 * into REC state via toolbar onClicked.
 */
async function bootSwInRecState(stub: BgChromeStub): Promise<PortStub> {
  installFileReaderPolyfill();
  (globalThis as unknown as GlobalWithBgChrome).chrome = stub;
  (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() };
  (globalThis as unknown as GlobalWithBgChrome).fetch = stubFetch as unknown as typeof fetch;

  await import('../../src/background/index');
  for (let i = 0; i < 8; i++) await Promise.resolve();

  const port = makePortStub();
  if (stub.runtime.onConnect._callbacks.length > 0) {
    stub.runtime.onConnect._callbacks[0](port);
  }

  const onMsg = stub.runtime.onMessage._callbacks[0];
  onMsg({ type: 'OFFSCREEN_READY' }, { id: 'ext-id-test' }, () => undefined);
  for (let i = 0; i < 16; i++) await Promise.resolve();

  const onClicked = stub.action.onClicked._callbacks[0];
  await onClicked();
  for (let i = 0; i < 32; i++) await Promise.resolve();

  return port;
}

// ──────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────

describe('Plan 02-01 Task 1 RED: downloadArchive uses blob: URL (D-P2-01)', () => {
  beforeEach(() => {
    vi.resetModules();
    vi.restoreAllMocks();
    delete (globalThis as unknown as GlobalWithBgChrome).chrome;
  });

  // ────────────────────────────────────────────────────────────────────
  // Test 1 — WIRE-FORMAT pin. The single chrome.downloads.download call
  // MUST carry a `url` starting with `blob:`. RED today because
  // downloadArchive (src/background/index.ts:695-718) still mints
  // `data:application/zip;base64,${base64}` via blobToBase64 +
  // chrome.downloads.download. GREEN after Plan 02-02 swaps that for an
  // offscreen-minted Blob URL.
  // ────────────────────────────────────────────────────────────────────
  it('calls chrome.downloads.download with a blob: URL and NOT a data:application/zip;base64, URL', async () => {
    const stub = buildBgStub();
    const port = await bootSwInRecState(stub);

    const downloadArg = await runSaveAndCaptureDownloadArg(stub, port);

    expect(
      downloadArg,
      'chrome.downloads.download was not called within the drain window. ' +
        'Either SAVE_ARCHIVE never reached downloadArchive (BUFFER routing broken) or ' +
        'the SW module structure changed — investigate before assuming this is the RED signal for D-P2-01.',
    ).toBeDefined();

    const url = downloadArg!.url;

    expect(
      url.startsWith('data:application/zip;base64,'),
      `chrome.downloads.download was called with url='${url.substring(0, 60)}...' ` +
        `but D-P2-01 forbids data:application/zip;base64, prefix (Chrome's ~2 MB cap ` +
        `weaponises P0-6). This polarity guard makes the RED→GREEN flip explicit.`,
    ).toBe(false);

    expect(
      url.startsWith('blob:'),
      `chrome.downloads.download was called with url='${url.substring(0, 60)}...' ` +
        `but D-P2-01 requires url.startsWith('blob:'). RED today because downloadArchive ` +
        `still mints data:application/zip;base64,... — flips GREEN after Plan 02-02 ` +
        `swaps to offscreen-minted Blob URL.`,
    ).toBe(true);
  });

  // ────────────────────────────────────────────────────────────────────
  // Test 2 — LATENCY + WIRE-FORMAT at the 6 MB scale.
  //
  // The synthetic 6 MB archive is injected by mocking remuxSegments
  // before the SW module loads. createArchive then assembles a zip
  // containing one ~6 MB "video/last_30sec.webm" entry plus meta.json
  // + rrweb + events + screenshot — the zip exceeds 6 MB. Under
  // Chrome's real ~2 MB data-URL cap this would reject; the vi.fn()
  // stub can't model rejection, so the test's load-bearing assertion is
  // the wire-format prefix at 6 MB scale (latency check captures the
  // base64-encoding regression vector but is necessary-not-sufficient).
  //
  // RED today (data: URL prefix + variable latency under current path).
  // GREEN after Plan 02-02 (blob: URL prefix; no encoding bottleneck).
  // ────────────────────────────────────────────────────────────────────
  it('completes downloadArchive within 5000ms for a 6 MB archive AND emits a blob: URL', { timeout: 30_000 }, async () => {
    // Pre-import: mock remuxSegments to short-circuit the muxer and
    // return a synthetic 6 MB Blob directly. createArchive's downstream
    // JSZip pack-up code + downloadArchive then run unchanged.
    vi.doMock('../../src/background/webm-remux', () => ({
      remuxSegments: vi.fn(async (_segments: unknown[]) => {
        const sixMb = new Uint8Array(SIX_MEGABYTES);
        return new Blob([sixMb], { type: 'video/webm' });
      }),
    }));

    const stub = buildBgStub();
    const port = await bootSwInRecState(stub);

    // Any non-empty segments array passes the createArchive empty-buffer
    // guard; the mocked remuxSegments ignores the input shape. The data
    // payload is a 4-byte dummy ("AAAA" base64-decoded) so the SW-side
    // filter in getVideoBufferFromOffscreen (which drops zero-size
    // segments per src/shared/binary.ts WR-07 fix) keeps the segment in
    // the array.
    const segments = [{
      data: 'AAAA',
      type: 'video/webm;codecs=vp9',
      timestamp: 1_000_000,
    }];

    const onMsg = stub.runtime.onMessage._callbacks[0];
    onMsg(
      { type: 'SAVE_ARCHIVE' },
      { id: 'ext-id-test' },
      () => undefined,
    );

    let buffered = false;
    // Plan 02-02 (D-P2-01): mirror runSaveAndCaptureDownloadArg's
    // offscreen-side CREATE_DOWNLOAD_URL handler so the SW's
    // downloadArchive can complete the bridge round-trip. The 6 MB
    // archive's base64 payload is sizeable (~8 MB after encoding); the
    // local URL.createObjectURL mint is still essentially free.
    const mintedRequestIds = new Set<string>();
    const t0 = performance.now();
    const DRAIN_ITERATIONS = 1_500;
    for (let i = 0; i < DRAIN_ITERATIONS; i++) {
      if (!buffered) {
        const reqCalls = port.postMessage.mock.calls.filter(
          (c: unknown[]) =>
            typeof c[0] === 'object' &&
            c[0] !== null &&
            (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER',
        );
        if (reqCalls.length > 0) {
          const reqMsg = reqCalls[0][0] as { requestId?: string };
          buffered = true;
          port.onMessage._listeners.forEach((fn) =>
            fn({
              type: 'BUFFER',
              requestId: reqMsg.requestId,
              segments,
            }),
          );
        }
      }
      const mintCalls = port.postMessage.mock.calls.filter(
        (c: unknown[]) =>
          typeof c[0] === 'object' &&
          c[0] !== null &&
          (c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL',
      );
      for (const call of mintCalls) {
        const mintMsg = call[0] as {
          requestId?: string;
          dataBase64?: string;
          mimeType?: string;
        };
        const requestId = mintMsg.requestId;
        if (typeof requestId !== 'string' || requestId.length === 0) continue;
        if (mintedRequestIds.has(requestId)) continue;
        mintedRequestIds.add(requestId);
        let url = '';
        try {
          const binary = atob(mintMsg.dataBase64 ?? '');
          const bytes = new Uint8Array(binary.length);
          for (let k = 0; k < binary.length; k++) bytes[k] = binary.charCodeAt(k);
          const blob = new Blob([bytes], { type: mintMsg.mimeType ?? 'application/zip' });
          url = URL.createObjectURL(blob);
        } catch {
          url = '';
        }
        port.onMessage._listeners.forEach((fn) =>
          fn({ type: 'DOWNLOAD_URL', requestId, url }),
        );
      }
      if (stub.downloads.download.mock.calls.length > 0) break;
      await new Promise<void>((resolve) => setTimeout(resolve, 5));
    }
    const elapsed = performance.now() - t0;

    expect(
      stub.downloads.download.mock.calls.length,
      `chrome.downloads.download was never called for the 6 MB archive after ${elapsed.toFixed(0)} ms ` +
        `drain. Valid RED mode under current base64 path — the EmptyVideoBufferError throw or ` +
        `the base64 encoding bottleneck can both gate the call.`,
    ).toBeGreaterThan(0);

    expect(
      elapsed,
      `downloadArchive end-to-end took ${elapsed.toFixed(0)} ms for a 6 MB payload. ` +
        `REQ-archive-export-latency floor is ${LATENCY_BUDGET_MS} ms. Above floor = ` +
        `base64 encoding bottleneck (latency RED signal); below floor + wrong wire format ` +
        `= polarity-guard RED signal.`,
    ).toBeLessThan(LATENCY_BUDGET_MS);

    const downloadArg = stub.downloads.download.mock.calls[0][0] as { url: string };
    expect(
      downloadArg.url.startsWith('blob:'),
      `chrome.downloads.download was called with url='${downloadArg.url.substring(0, 60)}...' ` +
        `at the 6 MB scale — D-P2-01 requires blob: prefix. The wire-format pin holds ` +
        `regardless of vi.fn() rejection modeling: current HEAD's data:application/zip;base64,... ` +
        `path fails this assertion under any payload size.`,
    ).toBe(true);

    vi.doUnmock('../../src/background/webm-remux');
  });

  // ────────────────────────────────────────────────────────────────────
  // Test 3 — REVOKE LIFECYCLE. URL.revokeObjectURL is scheduled for the
  // same url passed to chrome.downloads.download once
  // chrome.downloads.onChanged fires with state.current === 'complete'.
  //
  // RED today: the current downloadArchive does NOT mint a Blob URL at
  // all AND does NOT register an onChanged listener, so revokeObjectURL
  // is never called regardless of whether the synthetic 'complete'
  // event fires. Plan 02-02 wires both the mint AND the revoke-on-
  // complete listener.
  // ────────────────────────────────────────────────────────────────────
  it('schedules URL.revokeObjectURL with the minted url after chrome.downloads.onChanged complete', async () => {
    const stub = buildBgStub();
    const port = await bootSwInRecState(stub);

    const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});

    // Plan 02-02 (D-P2-01): the real offscreen handler in
    // src/offscreen/recorder.ts calls URL.revokeObjectURL on receipt of
    // REVOKE_DOWNLOAD_URL. The test stub does not load recorder.ts, so
    // we route the SW-posted REVOKE_DOWNLOAD_URL through revokeSpy here
    // to model the offscreen-side behaviour faithfully.
    port.postMessage.mockImplementation((msg: unknown) => {
      if (
        typeof msg === 'object' &&
        msg !== null &&
        (msg as { type?: unknown }).type === 'REVOKE_DOWNLOAD_URL'
      ) {
        const url = (msg as { url?: unknown }).url;
        if (typeof url === 'string' && url.length > 0) {
          URL.revokeObjectURL(url);
        }
      }
    });

    try {
      const downloadArg = await runSaveAndCaptureDownloadArg(stub, port);
      expect(
        downloadArg,
        'chrome.downloads.download never called — cannot probe revoke lifecycle.',
      ).toBeDefined();

      const mintedUrl = downloadArg!.url;
      const downloadId = (await stub.downloads.download.mock.results[0].value) as number;

      // GREEN under Plan 02-02: downloadArchive registers a chrome.downloads
      // .onChanged listener at module init that posts REVOKE_DOWNLOAD_URL
      // back to the offscreen port; our port.postMessage shim above mirrors
      // that into revokeSpy.
      stub.downloads.onChanged._callbacks.forEach((cb) =>
        cb({ id: downloadId, state: { current: 'complete' } }),
      );

      for (let i = 0; i < 32; i++) await Promise.resolve();
      await new Promise<void>((resolve) => setTimeout(resolve, 10));

      expect(
        revokeSpy.mock.calls.length,
        `URL.revokeObjectURL was never called after chrome.downloads.onChanged 'complete' fired. ` +
          `D-P2-01 lifecycle: SW (or offscreen) MUST revoke the minted blob: URL once the ` +
          `download finishes to avoid handle leaks. RED expected because the current SW does ` +
          `not register an onChanged listener (chrome.downloads.onChanged._callbacks.length === ` +
          `${stub.downloads.onChanged._callbacks.length} at probe time).`,
      ).toBeGreaterThan(0);

      expect(
        revokeSpy.mock.calls.some(
          (args: unknown[]) => args[0] === mintedUrl,
        ),
        `URL.revokeObjectURL was called but with the wrong URL. Expected: '${mintedUrl}'. ` +
          `Got: ${JSON.stringify(revokeSpy.mock.calls.map((c: unknown[]) => c[0]))}. ` +
          `The mint/revoke pair must be id-coupled — revoking a different URL leaks ` +
          `the current one and double-revokes a stale one.`,
      ).toBe(true);
    } finally {
      revokeSpy.mockRestore();
    }
  });
});
