// tests/build/strict-meta-json-validation.test.ts
//
// Plan 02-01 Task 3 (RED gate, Wave 0 of Phase 2).
// Pins decision D-P2-03 (strict 8-field meta.json schema validation)
// plus the F2 plan-checker-iter-1 resolution to PERMIT empty `urls[]`
// arrays as a meaningful representation of whole-desktop-no-tab
// sessions.
//
// Contract pinned here (3 RED today; 5 GREEN-today regression guards;
// ALL 8 GREEN after Plan 02-03):
//
//   1. Object.keys(meta).length === 8.           RED today (7 fields).
//   2. timestamp matches ISO-8601 Z-suffix regex. GREEN today (already
//      shaped via new Date().toISOString()).
//   3. urls is an Array of valid URLs; empty IS PERMITTED per F2.
//      RED today (urls is undefined).
//   4. extensionVersion matches semver.           GREEN today (sourced
//      from manifest version like '1.0.0').
//   5. totalEvents is a non-negative integer.     GREEN today (zero or
//      positive in createArchive).
//   6. videoBufferSeconds === 30.                  GREEN today (literal
//      in createArchive).
//   7. logDurationMinutes === 10.                  GREEN today (literal
//      in createArchive).
//   8. No extra fields (Object.keys subset of EXPECTED_KEYS, with
//      EXPECTED_KEYS including 'schemaVersion').  RED today
//      (meta.url is present + meta.urls + meta.schemaVersion missing).
//
// PLANNER-RESOLVED TENSIONS (see Plan 02-01 PLAN.md <action> block in
// Task 3 + CONTEXT.md Revision Log 2026-05-20):
//
//   D-P2-03 "non-empty urls[]" vs CONTEXT.md `<specifics>` permissive
//   empty-array clause: RESOLVED in favor of the permissive clause
//   (F2 — empty IS PERMITTED for whole-desktop-no-tab sessions). Test 3
//   relaxes the original D-P2-03 strict rule.
//
//   8th field name (`schemaVersion`): TENTATIVE PLANNER PICK to mark
//   the D-P2-02 url→urls breaking-change cutover. EXPECTED_KEYS
//   constant pins this choice; Plan 02-03's implementer adds
//   `schemaVersion: '2'` as a constant in src/background/index.ts. If
//   a stronger argument for a different 8th field surfaces, this test
//   is the canonical place to amend.
//
//   "ALL 8 fail" plan claim vs reality: the plan's <done> block claims
//   ALL 8 tests fail under current HEAD. In practice, Tests 2, 4, 5,
//   6, 7 already match the current 7-field meta.json (timestamp,
//   extensionVersion, totalEvents, videoBufferSeconds,
//   logDurationMinutes are all in the current shape and satisfy the
//   regex / range / literal checks). Only Tests 1, 3, 8 are RED today;
//   the other 5 are GREEN-today regression guards that ensure Plan
//   02-03 doesn't accidentally regress timestamp format, semver,
//   videoBufferSeconds, etc. when adding `urls` + `schemaVersion`.
//   Documented honestly in 02-01-SUMMARY.md.
//
// Test architecture: drive the SW the same way as
// tests/background/meta-json-urls-schema.test.ts (load SW module via
// dynamic import; wire chrome stub; drive SAVE_ARCHIVE; capture archive
// Blob from chrome.downloads.download arg0; unzip with JSZip; JSON.parse
// the meta.json entry). The single meta object is then probed by 8
// independent it() blocks under a shared describe.
//
// Skip discipline: NONE — bare it() blocks, no skip modifiers.

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
// ──────────────────────────────────────────────────────────────────────

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

// Canonical 8-field meta.json schema (post-Plan-02-03).
// EXPECTED_KEYS is the planner pin; the 8th field is `schemaVersion`
// to mark the D-P2-02 breaking-change cutover. Plan 02-03 implementer
// MUST add `schemaVersion` (recommended value: '2') to satisfy
// Tests 1 + 8 simultaneously.
const EXPECTED_KEYS: readonly string[] = Object.freeze([
  'timestamp',
  'urls',
  'userAgent',
  'extensionVersion',
  'videoBufferSeconds',
  'logDurationMinutes',
  'totalEvents',
  'schemaVersion',
]);
const EXPECTED_FIELD_COUNT = 8;

// Validation regexes / literals — pin D-P2-03 strict contract.
const ISO_8601_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
const SEMVER = /^\d+\.\d+\.\d+$/;
const URL_SCHEME_ALLOW = /^(https?|chrome-extension):\/\//;
const VIDEO_BUFFER_SECONDS_LITERAL = 30;
const LOG_DURATION_MINUTES_LITERAL = 10;

// ──────────────────────────────────────────────────────────────────────
// Chrome stub + helpers — minimal duplication of the pattern in
// meta-json-urls-schema.test.ts and blob-url-download.test.ts. Kept
// inline (no cross-file imports between tests/ trees, per vitest
// discouragement on cross-tree relative test imports).
// ──────────────────────────────────────────────────────────────────────

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 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: 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==',
      ),
    },
    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 — same rationale as the sibling test files.
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 SAVE_ARCHIVE → unzip → parse meta.json → return the parsed
 * object. The full pipeline runs once per `it()` so each test exercises
 * a fresh meta.json (vi.resetModules between tests ensures the SW
 * module state isn't carried over).
 *
 * @returns Parsed meta.json object, or undefined if download arg never
 *          captured.
 */
async function runAndParseMetaJson(
  stub: BgChromeStub,
  port: PortStub,
): Promise<Record<string, unknown> | 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 and capture the archive bytes from the port message itself
  // (the SW posts the base64-encoded payload before the URL is minted).
  // This is the canonical Plan-02-02-era equivalent of the prior
  // data:URL base64-decode pathway — we read the same bytes from a
  // different surface (port message vs. download URL).
  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);
        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: extract archive bytes from the captured port
  // payload, not from the blob: URL string (which is opaque in Node).
  let archiveBytes: Uint8Array | null = null;
  if (arg.url.startsWith('blob:') && capturedArchiveBytes !== null) {
    archiveBytes = capturedArchiveBytes;
  } else {
    const DATA_PREFIX = 'data:application/zip;base64,';
    if (arg.url.startsWith(DATA_PREFIX)) {
      const b64 = arg.url.substring(DATA_PREFIX.length);
      archiveBytes = Buffer.from(b64, 'base64');
    }
  }
  if (archiveBytes === null) return undefined;
  const archiveBlob = new Blob([archiveBytes], { type: 'application/zip' });
  const zip = await JSZip.loadAsync(archiveBlob);
  const metaEntry = zip.file('meta.json');
  if (!metaEntry) return undefined;
  const text = await metaEntry.async('string');
  return JSON.parse(text) as Record<string, unknown>;
}

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

/**
 * Per-test scaffold: boot SW, run SAVE_ARCHIVE, return parsed meta.json
 * with a precise expect-defined guard for the parse failure mode.
 */
async function getMetaOrFail(): Promise<Record<string, unknown>> {
  const stub = buildBgStub();
  const port = await bootSwInRecState(stub);
  const meta = await runAndParseMetaJson(stub, port);
  expect(
    meta,
    'meta.json could not be parsed from the SAVE_ARCHIVE flow. ' +
      'Either createArchive threw before the meta.json write OR the download URL changed ' +
      'shape (Plan 02-02 era — blob: URL pathway requires a different probe).',
  ).toBeDefined();
  return meta!;
}

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

describe('Plan 02-01 Task 3 RED: strict 8-field meta.json schema validation (D-P2-03 + F2)', () => {
  beforeEach(() => {
    vi.resetModules();
    vi.restoreAllMocks();
    delete (globalThis as unknown as GlobalWithBgChrome).chrome;
  });

  // ────────────────────────────────────────────────────────────────────
  // Test 1 — EXACT 8-FIELD COUNT. RED today (current shape has 7).
  // GREEN after Plan 02-03 adds `urls` + `schemaVersion` and removes
  // `url`.
  // ────────────────────────────────────────────────────────────────────
  it('meta.json has exactly 8 fields', async () => {
    const meta = await getMetaOrFail();
    const keys = Object.keys(meta);
    expect(
      keys.length,
      `meta.json has ${keys.length} fields; D-P2-03 requires exactly ${EXPECTED_FIELD_COUNT}. ` +
        `Current keys: ${JSON.stringify(keys)}. RED today; flips GREEN after Plan 02-03 ` +
        `adds 'urls' + 'schemaVersion' and removes 'url'.`,
    ).toBe(EXPECTED_FIELD_COUNT);
  });

  // ────────────────────────────────────────────────────────────────────
  // Test 2 — ISO-8601 TIMESTAMP. GREEN-today regression guard: the
  // current createArchive uses new Date().toISOString() which already
  // matches the Z-suffix regex. Stays GREEN after Plan 02-03; this test
  // catches any future regression that swaps toISOString() for a
  // locale-specific formatter.
  // ────────────────────────────────────────────────────────────────────
  it('meta.timestamp is ISO-8601 with Z suffix', async () => {
    const meta = await getMetaOrFail();
    expect(
      typeof meta.timestamp === 'string' && ISO_8601_Z.test(meta.timestamp as string),
      `meta.timestamp does not match ISO-8601 Z-suffix pattern. Got: ${JSON.stringify(meta.timestamp)}. ` +
        `Pattern: ${ISO_8601_Z.source}. D-P2-03 strict-validation contract.`,
    ).toBe(true);
  });

  // ────────────────────────────────────────────────────────────────────
  // Test 3 — URLS ARRAY OF VALID URLS (empty PERMITTED per F2).
  //
  // F2 resolution: the empty-tracker case is MEANINGFUL (whole-desktop
  // recording with no browser tabs open). The original D-P2-03 "non-
  // empty urls[]" strict clause is RELAXED here; empty IS PERMITTED.
  // CONTEXT.md `<specifics>` permissive empty-array clause wins over
  // the original D-P2-03 strict clause (Plan 02-04 Task 4 Step 2
  // operator empirical workflow verifies the non-empty case via the
  // multi-tab path).
  //
  // RED today (meta.urls is undefined; not an Array).
  // GREEN after Plan 02-03 (urls is an Array of filter-matching URLs;
  // may be empty for whole-desktop-no-tab sessions).
  // ────────────────────────────────────────────────────────────────────
  it('meta.urls is an Array of valid URLs; empty is permitted (F2)', async () => {
    const meta = await getMetaOrFail();
    expect(
      Array.isArray(meta.urls),
      `meta.urls is not an Array. Got: ${typeof meta.urls} (${JSON.stringify(meta.urls)}). ` +
        `D-P2-03 + F2 contract: urls is an Array (empty is permitted for whole-desktop-no-tab ` +
        `sessions; non-empty entries MUST each match ${URL_SCHEME_ALLOW.source}).`,
    ).toBe(true);

    const urls = meta.urls as unknown[];
    const allValid = urls.every(
      (u) => typeof u === 'string' && URL_SCHEME_ALLOW.test(u),
    );
    expect(
      allValid,
      `meta.urls contains non-string or invalid-scheme entries. URLs: ${JSON.stringify(urls)}. ` +
        `Per CONTEXT.md URL filter: include http/https + chrome-extension://; exclude ` +
        `chrome:// + about://. Each entry must match ${URL_SCHEME_ALLOW.source}.`,
    ).toBe(true);
  });

  // ────────────────────────────────────────────────────────────────────
  // Test 4 — EXTENSIONVERSION SEMVER. GREEN-today regression guard:
  // current createArchive reads from manifest.version which is '1.0.0'
  // (or any other semver). Stays GREEN; this test catches any future
  // regression that emits a non-semver version (build hash, "dev", etc).
  // ────────────────────────────────────────────────────────────────────
  it('meta.extensionVersion matches semver', async () => {
    const meta = await getMetaOrFail();
    expect(
      typeof meta.extensionVersion === 'string' && SEMVER.test(meta.extensionVersion as string),
      `meta.extensionVersion does not match semver pattern. Got: ` +
        `${JSON.stringify(meta.extensionVersion)}. Pattern: ${SEMVER.source}.`,
    ).toBe(true);
  });

  // ────────────────────────────────────────────────────────────────────
  // Test 5 — TOTALEVENTS NON-NEGATIVE INTEGER. GREEN-today regression
  // guard: current createArchive sets totalEvents = rrwebEvents.length +
  // userEvents.length which is always a non-negative integer. Stays
  // GREEN; catches negative/float regressions.
  // ────────────────────────────────────────────────────────────────────
  it('meta.totalEvents is a non-negative integer', async () => {
    const meta = await getMetaOrFail();
    expect(
      Number.isInteger(meta.totalEvents) && (meta.totalEvents as number) >= 0,
      `meta.totalEvents is not a non-negative integer. Got: ${JSON.stringify(meta.totalEvents)} ` +
        `(typeof: ${typeof meta.totalEvents}, isInteger: ${Number.isInteger(meta.totalEvents)}).`,
    ).toBe(true);
  });

  // ────────────────────────────────────────────────────────────────────
  // Test 6 — VIDEOBUFFERSECONDS LITERAL. GREEN-today regression guard:
  // current createArchive emits videoBufferSeconds: 30 literal. Stays
  // GREEN after Plan 02-03; this test catches any future regression that
  // changes the literal (CON-video-window contract).
  // ────────────────────────────────────────────────────────────────────
  it('meta.videoBufferSeconds is exactly 30 (CON-video-window)', async () => {
    const meta = await getMetaOrFail();
    expect(
      meta.videoBufferSeconds,
      `meta.videoBufferSeconds is not the CON-video-window literal ${VIDEO_BUFFER_SECONDS_LITERAL}. ` +
        `Got: ${JSON.stringify(meta.videoBufferSeconds)}.`,
    ).toBe(VIDEO_BUFFER_SECONDS_LITERAL);
  });

  // ────────────────────────────────────────────────────────────────────
  // Test 7 — LOGDURATIONMINUTES LITERAL. GREEN-today regression guard:
  // current createArchive emits logDurationMinutes: 10 literal. Stays
  // GREEN; catches CON-rrweb-window / CON-event-log-window regressions.
  // ────────────────────────────────────────────────────────────────────
  it('meta.logDurationMinutes is exactly 10 (CON-rrweb-window / CON-event-log-window)', async () => {
    const meta = await getMetaOrFail();
    expect(
      meta.logDurationMinutes,
      `meta.logDurationMinutes is not the CON-event-log-window literal ${LOG_DURATION_MINUTES_LITERAL}. ` +
        `Got: ${JSON.stringify(meta.logDurationMinutes)}.`,
    ).toBe(LOG_DURATION_MINUTES_LITERAL);
  });

  // ────────────────────────────────────────────────────────────────────
  // Test 8 — NO EXTRA FIELDS. Object.keys(meta) is a subset of
  // EXPECTED_KEYS (and EXPECTED_KEYS.length === 8). RED today: current
  // meta has `url` (singular) which is NOT in EXPECTED_KEYS (the new
  // schema has `urls` + `schemaVersion` replacing `url`).
  //
  // GREEN after Plan 02-03 adds `urls` + `schemaVersion` and removes
  // `url`.
  // ────────────────────────────────────────────────────────────────────
  it('meta.json has no extra fields beyond the 8 expected keys', async () => {
    const meta = await getMetaOrFail();
    const keys = Object.keys(meta);
    const extras = keys.filter((k) => !EXPECTED_KEYS.includes(k));
    const missing = EXPECTED_KEYS.filter((k) => !keys.includes(k));
    expect(
      extras.length,
      `meta.json contains extra (unexpected) fields: ${JSON.stringify(extras)}. ` +
        `EXPECTED_KEYS = ${JSON.stringify(EXPECTED_KEYS)}. Per D-P2-03 ` +
        `'8 fields exact', a strict superset is a regression vector for any future ` +
        `downstream consumer (UAT harness, future server upload). RED today because ` +
        `meta.url (singular) is in current shape but NOT in EXPECTED_KEYS.`,
    ).toBe(0);
    expect(
      missing.length,
      `meta.json is missing expected fields: ${JSON.stringify(missing)}. ` +
        `EXPECTED_KEYS = ${JSON.stringify(EXPECTED_KEYS)}. RED today because urls + ` +
        `schemaVersion are not in the current shape.`,
    ).toBe(0);
  });
});
