// tests/background/request-id-protocol.test.ts
//
// RED-gate tests for the Option C architectural refactor's SW-side
// contract, per .planning/debug/empty-archive-port-race.md (Fix Strategy:
// Option C). Companion to tests/offscreen/port-health-probe.test.ts.
//
// SW-side architectural responsibilities under the new design:
//
//   1. **Request-id'd BUFFER routing** — getVideoBufferFromOffscreen
//      generates a uuid, sends `{type:'REQUEST_BUFFER', requestId}`,
//      and only resolves on BUFFER messages that echo the same id.
//      Stale BUFFERs from prior requests are ignored.
//
//   2. **Retry on port replacement** — if `videoPort` changes (e.g. the
//      offscreen health probe reconnected the port mid-request), the
//      SW re-issues REQUEST_BUFFER on the new port with the SAME
//      requestId. This is the architectural mechanism that retires the
//      H2 silent-drop class — the BUFFER reaches the SW regardless of
//      port-replacement timing.
//
//   3. **Operator-visible error surface on empty video** — createArchive
//      throws when there are zero video segments. saveArchive catches
//      and returns `{success: false, error}` so the popup can surface
//      RECORDING_ERROR to the operator. Replaces the upstream silent-
//      skip branch (src/background/index.ts:346-352) that shipped a
//      video-less archive in silence.
//
//   4. **PING → PONG echo** — the SW-side port message sink replies to
//      PING with a PONG response, completing the offscreen's health-
//      probe loop.
//
// These are the SW-side acceptance criteria for the Option C refactor.
// All four flip GREEN when the implementation lands in src/background/index.ts.

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

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: (resp: unknown) => void
  ): boolean;
}

interface BackgroundChromeStub {
  runtime: {
    id: string;
    getURL: (path: 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> };
  };
  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>;
  };
}

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

function buildBackgroundChromeStub(): BackgroundChromeStub {
  const stub: BackgroundChromeStub = {
    runtime: {
      id: 'ext-id-test',
      getURL: (p: string) => `chrome-extension://ext-id-test/${p}`,
      getManifest: () => ({ version: '1.0.0' }),
      sendMessage: vi.fn(),
      onMessage: { addListener: vi.fn(), _callbacks: [] },
      onConnect: { addListener: (cb) => stub.runtime.onConnect._callbacks.push(cb), _callbacks: [] },
      onInstalled: { addListener: vi.fn() },
    },
    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(1),
    },
  };
  stub.runtime.onMessage.addListener.mockImplementation((cb: OnMessageCallback) => {
    stub.runtime.onMessage._callbacks.push(cb);
  });
  return stub;
}

// Generic Promise-resolved fetch that returns a fake screenshot Blob.
async function stubFetch(_url: string): Promise<Response> {
  // The SW's captureScreenshot does `await fetch(dataUrl).blob()`. We
  // return a minimal PNG Blob.
  const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
  return {
    blob: async () => new Blob([png], { type: 'image/png' }),
  } as unknown as Response;
}

describe('SW request-id protocol + retry + error surface (RED — Option C)', () => {
  beforeEach(() => {
    vi.resetModules();
  });

  // ──────────────────────────────────────────────────────────────────────
  // 1. REQUEST_BUFFER carries a requestId — the SW generates one per call
  // ──────────────────────────────────────────────────────────────────────
  it('1: getVideoBufferFromOffscreen sends REQUEST_BUFFER with a requestId', async () => {
    const stub = buildBackgroundChromeStub();
    (globalThis as unknown as GlobalWithBackgroundChrome).chrome = stub;
    (globalThis as unknown as GlobalWithBackgroundChrome).indexedDB = {
      deleteDatabase: vi.fn(),
    };
    (globalThis as unknown as GlobalWithBackgroundChrome).fetch = stubFetch as unknown as typeof fetch;

    await import('../../src/background/index');

    // Synthetically wire an offscreen port.
    expect(stub.runtime.onConnect._callbacks.length).toBeGreaterThanOrEqual(1);
    const port = makePortStub();
    stub.runtime.onConnect._callbacks[0](port);

    // Trigger SAVE_ARCHIVE via the onMessage path. Capture the sendResponse
    // callback so we can resolve a synthetic BUFFER response.
    let saveResp: unknown = undefined;
    const sendResp = (r: unknown) => {
      saveResp = r;
    };
    const onMessageHandler = stub.runtime.onMessage._callbacks[0];
    onMessageHandler(
      { type: 'SAVE_ARCHIVE' },
      { id: 'ext-id-test' },
      sendResp,
    );

    // Drain microtasks so the saveArchive chain calls getVideoBufferFromOffscreen.
    for (let i = 0; i < 32; i++) await Promise.resolve();
    await new Promise<void>((resolve) => setTimeout(resolve, 0));
    for (let i = 0; i < 32; i++) await Promise.resolve();

    // The first message posted on the port must be REQUEST_BUFFER WITH a
    // requestId field. Plain `{type:'REQUEST_BUFFER'}` without a requestId
    // is the legacy shape and not acceptable under Option C.
    const requestBufferCalls = port.postMessage.mock.calls.filter(
      (c: unknown[]) =>
        typeof c[0] === 'object' &&
        c[0] !== null &&
        (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER'
    );
    expect(requestBufferCalls.length).toBeGreaterThanOrEqual(1);
    const reqMsg = requestBufferCalls[0][0] as { requestId?: unknown };
    expect(typeof reqMsg.requestId).toBe('string');
    expect((reqMsg.requestId as string).length).toBeGreaterThan(0);

    // Cleanup — resolve the in-flight save by delivering a matching
    // BUFFER so the test doesn't leave a dangling Promise.
    port.onMessage._listeners.forEach((fn) =>
      fn({ type: 'BUFFER', requestId: reqMsg.requestId, segments: [] })
    );
    for (let i = 0; i < 32; i++) await Promise.resolve();
    void saveResp;
  });

  // ──────────────────────────────────────────────────────────────────────
  // 2. Stale BUFFER (different requestId) is ignored
  // ──────────────────────────────────────────────────────────────────────
  it('2: SW ignores BUFFER with a mismatched requestId (no cross-talk)', async () => {
    const stub = buildBackgroundChromeStub();
    (globalThis as unknown as GlobalWithBackgroundChrome).chrome = stub;
    (globalThis as unknown as GlobalWithBackgroundChrome).indexedDB = {
      deleteDatabase: vi.fn(),
    };
    (globalThis as unknown as GlobalWithBackgroundChrome).fetch = stubFetch as unknown as typeof fetch;

    await import('../../src/background/index');

    const port = makePortStub();
    stub.runtime.onConnect._callbacks[0](port);

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

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

    const requestBufferCalls = port.postMessage.mock.calls.filter(
      (c: unknown[]) =>
        typeof c[0] === 'object' &&
        c[0] !== null &&
        (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER'
    );
    const reqMsg = requestBufferCalls[0][0] as { requestId: string };

    // Deliver a STALE BUFFER (different requestId) — the SW must NOT
    // resolve its in-flight request on this.
    port.onMessage._listeners.forEach((fn) =>
      fn({
        type: 'BUFFER',
        requestId: 'completely-different-id-xxx',
        segments: [
          {
            data: 'GkXfow==',
            type: 'video/webm',
            timestamp: 1,
          },
        ],
      })
    );

    // Now drain microtasks; the in-flight request must still be pending.
    // We confirm by observing that saveResp is still undefined.
    for (let i = 0; i < 32; i++) await Promise.resolve();
    expect(saveResp).toBeUndefined();

    // Deliver the matching BUFFER to resolve cleanly.
    port.onMessage._listeners.forEach((fn) =>
      fn({ type: 'BUFFER', requestId: reqMsg.requestId, segments: [] })
    );
    for (let i = 0; i < 32; i++) await Promise.resolve();
    await new Promise<void>((resolve) => setTimeout(resolve, 0));
    for (let i = 0; i < 32; i++) await Promise.resolve();
  });

  // ──────────────────────────────────────────────────────────────────────
  // 3. SW retries REQUEST_BUFFER on port replacement
  // ──────────────────────────────────────────────────────────────────────
  //
  // The architectural fix: when `videoPort` is replaced by a fresh
  // onConnect call (the offscreen reconnected its port mid-request), the
  // SW re-issues REQUEST_BUFFER on the new port with the same requestId.
  // This is the mechanism that retires the H2 silent-drop class.
  it('3: SW re-issues REQUEST_BUFFER on the new port after a port replacement', async () => {
    const stub = buildBackgroundChromeStub();
    (globalThis as unknown as GlobalWithBackgroundChrome).chrome = stub;
    (globalThis as unknown as GlobalWithBackgroundChrome).indexedDB = {
      deleteDatabase: vi.fn(),
    };
    (globalThis as unknown as GlobalWithBackgroundChrome).fetch = stubFetch as unknown as typeof fetch;

    await import('../../src/background/index');

    const oldPort = makePortStub();
    stub.runtime.onConnect._callbacks[0](oldPort);

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

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

    const oldRequestBufferCalls = oldPort.postMessage.mock.calls.filter(
      (c: unknown[]) =>
        typeof c[0] === 'object' &&
        c[0] !== null &&
        (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER'
    );
    expect(oldRequestBufferCalls.length).toBeGreaterThanOrEqual(1);
    const reqMsg = oldRequestBufferCalls[0][0] as { requestId: string };

    // Simulate port replacement: fire onDisconnect on the old port, then
    // a fresh onConnect for the new one.
    oldPort.onDisconnect._listeners.forEach((fn) => fn());
    const newPort = makePortStub();
    stub.runtime.onConnect._callbacks[0](newPort);

    // Drain microtasks for the SW to observe the new port and retry.
    for (let i = 0; i < 32; i++) await Promise.resolve();
    await new Promise<void>((resolve) => setTimeout(resolve, 0));
    for (let i = 0; i < 32; i++) await Promise.resolve();

    // The new port must have received a REQUEST_BUFFER too (the retry),
    // preserving the SAME requestId so the SW can route the eventual
    // BUFFER response back to the original in-flight Promise.
    const newRequestBufferCalls = newPort.postMessage.mock.calls.filter(
      (c: unknown[]) =>
        typeof c[0] === 'object' &&
        c[0] !== null &&
        (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER'
    );
    expect(newRequestBufferCalls.length).toBeGreaterThanOrEqual(1);
    const retryReq = newRequestBufferCalls[0][0] as { requestId: string };
    expect(retryReq.requestId).toBe(reqMsg.requestId);

    // Deliver BUFFER on the new port — saveArchive resolves.
    newPort.onMessage._listeners.forEach((fn) =>
      fn({ type: 'BUFFER', requestId: reqMsg.requestId, segments: [] })
    );
    for (let i = 0; i < 32; i++) await Promise.resolve();
    await new Promise<void>((resolve) => setTimeout(resolve, 0));
    for (let i = 0; i < 32; i++) await Promise.resolve();
    void saveResp;
  });

  // ──────────────────────────────────────────────────────────────────────
  // 4. Operator-visible error surface on empty video
  // ──────────────────────────────────────────────────────────────────────
  //
  // The upstream H2 silent-skip in createArchive must be retired.
  // saveArchive must return `{success: false, error}` (not
  // `{success: true}` with a video-less archive) when the video buffer
  // is empty AND the archive would therefore ship without video.
  //
  // The popup already handles `{success: false, error}` by surfacing
  // RECORDING_ERROR to the operator.
  it('4: saveArchive returns success:false when video segments are empty', async () => {
    const stub = buildBackgroundChromeStub();
    (globalThis as unknown as GlobalWithBackgroundChrome).chrome = stub;
    (globalThis as unknown as GlobalWithBackgroundChrome).indexedDB = {
      deleteDatabase: vi.fn(),
    };
    (globalThis as unknown as GlobalWithBackgroundChrome).fetch = stubFetch as unknown as typeof fetch;

    await import('../../src/background/index');

    const port = makePortStub();
    stub.runtime.onConnect._callbacks[0](port);

    let saveResp: { success?: boolean; error?: unknown } | undefined;
    stub.runtime.onMessage._callbacks[0](
      { type: 'SAVE_ARCHIVE' },
      { id: 'ext-id-test' },
      (r) => {
        saveResp = r as { success?: boolean; error?: unknown };
      },
    );

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

    const requestBufferCalls = port.postMessage.mock.calls.filter(
      (c: unknown[]) =>
        typeof c[0] === 'object' &&
        c[0] !== null &&
        (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER'
    );
    const reqMsg = requestBufferCalls[0][0] as { requestId: string };

    // Resolve BUFFER with ZERO segments — the silent-skip scenario.
    port.onMessage._listeners.forEach((fn) =>
      fn({ type: 'BUFFER', requestId: reqMsg.requestId, segments: [] })
    );

    // Drain microtasks for saveArchive to complete.
    for (let i = 0; i < 64; i++) await Promise.resolve();
    await new Promise<void>((resolve) => setTimeout(resolve, 0));
    for (let i = 0; i < 64; i++) await Promise.resolve();
    await new Promise<void>((resolve) => setTimeout(resolve, 0));
    for (let i = 0; i < 64; i++) await Promise.resolve();

    expect(saveResp).toBeDefined();
    expect(saveResp?.success).toBe(false);
    expect(saveResp?.error).toBeDefined();
  });

  // ──────────────────────────────────────────────────────────────────────
  // 5. SW echoes PONG on PING (health-probe loop closure)
  // ──────────────────────────────────────────────────────────────────────
  //
  // The offscreen's health probe expects a PONG echo per PING. The SW's
  // port message sink must reply.
  it('5: SW replies to PING with PONG (closes the health-probe loop)', async () => {
    const stub = buildBackgroundChromeStub();
    (globalThis as unknown as GlobalWithBackgroundChrome).chrome = stub;
    (globalThis as unknown as GlobalWithBackgroundChrome).indexedDB = {
      deleteDatabase: vi.fn(),
    };
    (globalThis as unknown as GlobalWithBackgroundChrome).fetch = stubFetch as unknown as typeof fetch;

    await import('../../src/background/index');

    const port = makePortStub();
    stub.runtime.onConnect._callbacks[0](port);

    // Dispatch PING.
    expect(port.onMessage._listeners.length).toBeGreaterThanOrEqual(1);
    port.onMessage._listeners.forEach((fn) => fn({ type: 'PING' }));

    // Drain microtasks.
    for (let i = 0; i < 16; i++) await Promise.resolve();

    // The SW must have responded with PONG via postMessage on the port.
    const pongCalls = port.postMessage.mock.calls.filter(
      (c: unknown[]) =>
        typeof c[0] === 'object' &&
        c[0] !== null &&
        (c[0] as { type?: unknown }).type === 'PONG'
    );
    expect(pongCalls.length).toBeGreaterThanOrEqual(1);
  });
});
