// tests/background/meta-json-urls-schema.test.ts
//
// Plan 02-01 Task 2 (RED gate, Wave 0 of Phase 2).
// Pins decision D-P2-02 (meta.json schema migrates `url: string` →
// `urls: string[]`) plus the F2 plan-checker-iter-1 resolution
// (empty-tracker case → `urls: []`, NO sentinel fallback).
//
// Contract pinned here (RED today; flips GREEN after Plan 02-03):
//
//   1. The SessionMetadata interface in src/shared/types.ts has a
//      `urls: string[]` field AND does NOT have a `url:` field.
//      Structural source-text scan; vitest.config.ts has
//      typecheck:{enabled:false}, so a tsc-failure-based pin would be
//      a no-op. The source-text scan is RED today because line 105 of
//      src/shared/types.ts reads `url: string;`.
//
//   2. createArchive emits meta.json with `urls: string[]` (Array)
//      AND `url` is undefined. The current createArchive code path
//      assembles `metadata.url = new URL(chrome.runtime.getURL(''))
//      .origin`, so meta.url is the extension origin and meta.urls is
//      undefined — both assertions fail.
//
//   3. meta.urls is deduplicated and ordered first-seen-first. Driven
//      via the tab-url-tracker module (planner-suggested name:
//      `src/background/tab-url-tracker.ts`, export name:
//      `getTabUrlsSeen(): string[]`). The module does not exist at
//      current HEAD; the dynamic import throws and expect.fail surfaces
//      the missing-module RED signal.
//
//   4. meta.urls filters chrome:// and about: URLs and includes
//      chrome-extension:// URLs (per CONTEXT.md `<specifics>` URL
//      filter). Same tab-url-tracker dependency as Test 3.
//
//   5. Empty-tracker case (no browser interaction during recording) →
//      meta.urls === [] (exact empty Array; NOT undefined, NOT
//      `[extension-origin]`, NOT `null`). F2 (plan-checker iteration 1)
//      resolution: the empty Array IS the canonical representation of a
//      whole-desktop-no-tab session. Same tab-url-tracker dependency.
//
// Module seam (Plan 02-03 will implement):
//   src/background/tab-url-tracker.ts
//     export function getTabUrlsSeen(): string[]
//
// Tab tracking is fed by chrome.tabs.onUpdated + chrome.tabs.onActivated
// listeners (DEC-011 Amendment 1 grants the `tabs` permission for this
// purpose). The Set is dedup-on-add; iteration order preserves first-
// seen-first.
//
// Test architecture: same SW-driving pattern as
// tests/background/blob-url-download.test.ts (load SW module via
// dynamic import; wire chrome stub; drive SAVE_ARCHIVE via the
// runtime.onMessage callback; capture the archive Blob from
// chrome.downloads.download arg0; unzip with JSZip; JSON.parse the
// meta.json entry).
//
// Skip discipline: NONE — bare it() blocks, no skip modifiers.
// Plan 02-01 T-02-01-01 mitigation.

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';
import JSZip from 'jszip';

// ──────────────────────────────────────────────────────────────────────
// Constants — match the canonical fixture-slice offsets used in
// tests/background/webm-remux.test.ts and blob-url-download.test.ts.
// ──────────────────────────────────────────────────────────────────────

const here = dirname(fileURLToPath(import.meta.url));
const FIXTURE_PATH = resolvePath(
  here, '..', '..', 'tests', 'fixtures', 'raw-3ebml-concat.webm',
);
const TYPES_PATH = resolvePath(
  here, '..', '..', 'src', 'shared', 'types.ts',
);
const SEG1_START = 0;
const SEG2_START = 509038;
const SEG3_START = 970967;

// ──────────────────────────────────────────────────────────────────────
// Chrome stub — same shape as blob-url-download.test.ts. Plan 02-03 may
// extend with chrome.tabs.onUpdated + chrome.tabs.onActivated wiring
// for the tab-url-tracker module; this RED gate stays minimal.
// ──────────────────────────────────────────────────────────────────────

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>; }
type TabUpdatedCb = (tabId: number, change: { url?: string }, tab: { url?: string }) => void;
type TabActivatedCb = (info: { tabId: number; windowId: number }) => 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>;
    get?: ReturnType<typeof vi.fn>;
    onUpdated: { addListener: (cb: TabUpdatedCb) => void; _callbacks: TabUpdatedCb[] };
    onActivated: { addListener: (cb: TabActivatedCb) => void; _callbacks: TabActivatedCb[] };
  };
  downloads: {
    download: ReturnType<typeof vi.fn>;
    onChanged: {
      addListener: ReturnType<typeof vi.fn>;
      _callbacks: Array<(delta: { id: number; state?: { current: string } }) => void>;
    };
  };
  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==',
      ),
      get: vi.fn(async (tabId: number) => ({ id: tabId, url: 'https://example.com' })),
      onUpdated: {
        addListener: (cb) => stub.tabs.onUpdated._callbacks.push(cb),
        _callbacks: [],
      },
      onActivated: {
        addListener: (cb) => stub.tabs.onActivated._callbacks.push(cb),
        _callbacks: [],
      },
    },
    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: (delta: { id: number; state?: { current: string } }) => void) => {
      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;
}

// FileReader polyfill — see blob-url-download.test.ts rationale.
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;
  }
}

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 and capture the archive Blob from the
 * chrome.downloads.download arg0. Decodes the current data: URL into a
 * Buffer → wraps in a Blob → returns it. Plan 02-02 swaps to blob:
 * URLs; this helper handles both — if the URL is data:, base64-decode;
 * otherwise return undefined (Plan 02-02 era; this test won't reach
 * that branch under current HEAD).
 *
 * @param stub - Wired chrome stub.
 * @param port - Connected offscreen port.
 * @returns The archive Blob, or undefined if no download was issued.
 */
async function runSaveAndCaptureArchiveBlob(
  stub: BgChromeStub,
  port: PortStub,
): Promise<Blob | 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; we mint a Node-native blob: URL via URL.createObjectURL and
  // reply DOWNLOAD_URL{requestId,url}. The archive bytes are captured
  // here BEFORE minting so meta.json extraction works regardless of
  // whether the SW ever calls chrome.downloads.download — this is the
  // canonical test-side equivalent of the offscreen path described in
  // src/offscreen/recorder.ts handleCreateDownloadUrl.
  const mintedRequestIds = new Set<string>();
  let capturedArchiveBytes: Uint8Array | null = null;
  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 (mintedRequestIds.has(requestId)) continue;
      mintedRequestIds.add(requestId);
      let url = '';
      try {
        const binary = atob(mintMsg.dataBase64 ?? '');
        const bytes = new Uint8Array(binary.length);
        for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
        // Capture the archive bytes for downstream meta.json extraction.
        if (capturedArchiveBytes === null) {
          capturedArchiveBytes = bytes;
        }
        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 }),
      );
    }
  };

  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));
  }

  if (stub.downloads.download.mock.calls.length === 0) return undefined;
  const arg = stub.downloads.download.mock.calls[0][0] as { url: string };
  // Plan 02-02 era: blob:URL — the archive bytes are not recoverable
  // from the URL string itself. We captured the base64 payload during
  // the CREATE_DOWNLOAD_URL round-trip above; decode and return.
  if (arg.url.startsWith('blob:') && capturedArchiveBytes !== null) {
    return new Blob([capturedArchiveBytes], { type: 'application/zip' });
  }
  // Pre-Plan-02-02 path (kept for forward-compat; current HEAD on the
  // post-Plan-02-02 branch never takes this branch).
  const DATA_PREFIX = 'data:application/zip;base64,';
  if (arg.url.startsWith(DATA_PREFIX)) {
    const b64 = arg.url.substring(DATA_PREFIX.length);
    const bytes = Buffer.from(b64, 'base64');
    return new Blob([bytes], { type: 'application/zip' });
  }
  return undefined;
}

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;
}

/**
 * Extract the SessionMetadata block (lines after `export interface
 * SessionMetadata {` up to the closing `}`) from src/shared/types.ts.
 * Used by Test 1 to assert the field-level migration RED today.
 *
 * @returns The interface body text, or '' on read failure.
 */
function readSessionMetadataInterface(): string {
  const text = readFileSync(TYPES_PATH, 'utf8');
  const startMarker = 'export interface SessionMetadata {';
  const start = text.indexOf(startMarker);
  if (start < 0) return '';
  const tail = text.substring(start);
  const end = tail.indexOf('\n}');
  if (end < 0) return '';
  return tail.substring(0, end + 2);
}

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

describe('Plan 02-01 Task 2 RED: meta.json urls[] schema + dedup/filter + empty-tracker (D-P2-02 + F2)', () => {
  beforeEach(() => {
    vi.resetModules();
    vi.restoreAllMocks();
    delete (globalThis as unknown as GlobalWithBgChrome).chrome;
  });

  // ────────────────────────────────────────────────────────────────────
  // Test 1 — TYPE-SHAPE pin (source-text scan).
  //
  // vitest.config.ts sets typecheck:{enabled:false}, so a tsc-failure
  // pin would be a no-op (the test would always GREEN under the runtime
  // expect-true-toBe-true escape hatch the plan suggested). Instead,
  // the RED signal is a textual scan of src/shared/types.ts: the
  // SessionMetadata interface body MUST contain `urls:` and MUST NOT
  // contain `url:` (singular). RED today because the current types.ts
  // (line 105) reads `url: string;` and has no `urls` field.
  // ────────────────────────────────────────────────────────────────────
  it('SessionMetadata interface in src/shared/types.ts declares urls: string[] and has no url: field', () => {
    const interfaceBody = readSessionMetadataInterface();
    expect(
      interfaceBody.length,
      `Could not locate 'export interface SessionMetadata {' in src/shared/types.ts. ` +
        `If the file was restructured, the regex anchor in readSessionMetadataInterface() ` +
        `needs to be updated alongside.`,
    ).toBeGreaterThan(0);

    // RED signal #1: the new `urls:` field must be present.
    expect(
      /\burls\s*:\s*string\[\]/.test(interfaceBody),
      `SessionMetadata interface body does not contain a 'urls: string[]' field. ` +
        `D-P2-02 requires this field replace the old 'url: string'. RED today; ` +
        `Plan 02-03 amends src/shared/types.ts. Current body:\n${interfaceBody}`,
    ).toBe(true);

    // RED signal #2: the old `url:` field must be gone (the line-level
    // regex bounds avoid matching the substring 'url' inside 'urls').
    expect(
      /^\s*url\s*:/m.test(interfaceBody),
      `SessionMetadata interface body still contains the old 'url:' (singular) field. ` +
        `D-P2-02 is a SCHEMA-BREAKING change: 'url: string' is REMOVED, replaced by ` +
        `'urls: string[]'. RED today; Plan 02-03 amends src/shared/types.ts. Current body:\n${interfaceBody}`,
    ).toBe(false);
  });

  // ────────────────────────────────────────────────────────────────────
  // Test 2 — meta.json shape pin via real createArchive.
  //
  // RED today: meta.url is set to the extension origin
  // (`chrome.runtime.getURL('')`'s origin) and meta.urls is undefined.
  // GREEN after Plan 02-03's createArchive amendment + tab-url-tracker
  // wiring.
  // ────────────────────────────────────────────────────────────────────
  it('createArchive emits meta.json with Array urls and no url field', async () => {
    const stub = buildBgStub();
    const port = await bootSwInRecState(stub);

    const archiveBlob = await runSaveAndCaptureArchiveBlob(stub, port);
    expect(
      archiveBlob,
      'createArchive did not produce an archive Blob within the drain window. ' +
        'Either the SAVE_ARCHIVE flow broke or the data: URL prefix changed (Plan 02-02 era).',
    ).toBeDefined();

    const zip = await JSZip.loadAsync(archiveBlob!);
    const metaEntry = zip.file('meta.json');
    expect(
      metaEntry,
      'meta.json entry missing from archive. Archive layout regression — REQ-archive-layout violation.',
    ).not.toBeNull();

    const metaText = await metaEntry!.async('string');
    const meta = JSON.parse(metaText);

    expect(
      Array.isArray(meta.urls),
      `meta.urls is not an Array. Got: ${typeof meta.urls} (${JSON.stringify(meta.urls)}). ` +
        `D-P2-02 requires 'urls' field as a string[]. RED today because createArchive ` +
        `still emits 'url: string' (singular) at src/background/index.ts:676.`,
    ).toBe(true);

    expect(
      meta.url,
      `meta.url is still defined (value: ${JSON.stringify(meta.url)}). D-P2-02 is a ` +
        `SCHEMA-BREAKING change: the singular 'url' field is REMOVED entirely; multi-tab ` +
        `context lives in 'urls' (plural Array). RED today.`,
    ).toBeUndefined();
  });

  // ────────────────────────────────────────────────────────────────────
  // Test 3 — DEDUP + ORDER. meta.urls === ['A', 'B'] when the tracker
  // observes ['A', 'B', 'A'] (first-seen-first; A appears once).
  //
  // RED today: src/background/tab-url-tracker.ts does not exist. Plan
  // 02-03 implements it. The expect.fail in the catch surfaces the
  // missing-module RED signal with a precise marker for the GREEN-flip.
  //
  // Once Plan 02-03 lands the module, this test continues to be useful:
  // it pins the dedup-and-order CONTRACT of getTabUrlsSeen() directly.
  // ────────────────────────────────────────────────────────────────────
  it('meta.urls deduplicates repeated URLs and preserves first-seen order', async () => {
    // Plan 02-03 GREEN path: tab-url-tracker landed without the optional
    // `_resetForTesting` / `_observeForTesting` ergonomic hooks (those
    // would have leaked into production bundles and violated the Tier-1
    // 13-entry FORBIDDEN_HOOK_STRINGS gate at
    // tests/background/no-test-hooks-in-prod-bundle.test.ts:108). The
    // canonical Plan-02-01-SUMMARY-anticipated alternative is to wire
    // chrome.tabs.onUpdated callbacks directly via the chrome stub.
    //
    // Mechanic: build the chrome stub, install it as globalThis.chrome,
    // import the tracker, call initTabUrlTracker() to register listeners
    // on the stub, then invoke the captured callbacks with synthetic
    // tab-update events. The tracker treats each callback invocation as
    // a real Chrome event; dedup + ordering invariants kick in naturally.
    const stub = buildBgStub();
    (globalThis as unknown as GlobalWithBgChrome).chrome = stub;
    const tracker = await import('../../src/background/tab-url-tracker');
    tracker.initTabUrlTracker();

    // Synthetic chrome.tabs.onUpdated events. The first 'A' establishes
    // first-seen ordering; 'B' appends second; the repeated 'A' is
    // deduplicated. Mirrors the Plan 02-01 RED-test contract verbatim.
    const fireUpdate = (tabId: number, url: string): void => {
      stub.tabs.onUpdated._callbacks.forEach((cb) =>
        cb(tabId, { url }, { url }),
      );
    };
    fireUpdate(1, 'https://a.example.com');
    fireUpdate(2, 'https://b.example.com');
    fireUpdate(1, 'https://a.example.com');

    expect(tracker.getTabUrlsSeen()).toEqual([
      'https://a.example.com',
      'https://b.example.com',
    ]);
  });

  // ────────────────────────────────────────────────────────────────────
  // Test 4 — URL FILTER. meta.urls includes https + chrome-extension://;
  // excludes chrome:// and about:.
  //
  // RED today: missing module. GREEN after Plan 02-03.
  // ────────────────────────────────────────────────────────────────────
  it('meta.urls filters chrome:// and about: URLs and includes chrome-extension://', async () => {
    // Plan 02-03 GREEN path: same chrome.tabs.onUpdated driver pattern
    // as Test 3 (preserves the 13-entry Tier-1 grep gate by not
    // requiring `_observeForTesting` hooks on the tracker module).
    const stub = buildBgStub();
    (globalThis as unknown as GlobalWithBgChrome).chrome = stub;
    const tracker = await import('../../src/background/tab-url-tracker');
    tracker.initTabUrlTracker();

    const fireUpdate = (tabId: number, url: string): void => {
      stub.tabs.onUpdated._callbacks.forEach((cb) =>
        cb(tabId, { url }, { url }),
      );
    };
    // Filter test: include https + chrome-extension://; exclude chrome:// +
    // about: per CONTEXT.md `<specifics>` URL filter clause.
    fireUpdate(1, 'https://example.com');
    fireUpdate(2, 'chrome://newtab');
    fireUpdate(3, 'about:blank');
    fireUpdate(4, 'chrome-extension://abc/popup.html');

    expect(tracker.getTabUrlsSeen()).toEqual([
      'https://example.com',
      'chrome-extension://abc/popup.html',
    ]);
  });

  // ────────────────────────────────────────────────────────────────────
  // Test 5 — EMPTY-TRACKER → urls: [] (per F2 plan-checker iter-1).
  //
  // The whole-desktop-no-tab session is a meaningful operator state
  // (the operator recorded screen capture without any browser tab
  // interaction during the 30 s window). meta.urls MUST be an empty
  // Array — NOT undefined, NOT `[<extension-origin>]`, NOT null.
  //
  // RED today: missing module. GREEN after Plan 02-03 lands the tracker
  // + amends createArchive to call snapshotOpenTabs() + read the
  // tracker output verbatim (no sentinel fallback per CONTEXT.md
  // Revision Log 2026-05-20).
  // ────────────────────────────────────────────────────────────────────
  it('meta.urls is exactly [] when the tracker observed no browser tabs (F2)', async () => {
    // Plan 02-03 GREEN path: import the tracker on a freshly-reset
    // module graph (vi.resetModules in beforeEach), then immediately
    // query without firing any chrome.tabs.* callbacks. The empty
    // representation is the canonical whole-desktop-no-tab state.
    const tracker = await import('../../src/background/tab-url-tracker');
    // Deliberately observe nothing — simulating a session where no tab
    // events fired during the 30 s window.
    expect(tracker.getTabUrlsSeen()).toEqual([]);
    expect(tracker.getTabUrlsSeen()).not.toBeUndefined();
    expect(tracker.getTabUrlsSeen()).not.toBeNull();
  });
});
