// tests/background/port-lifecycle-continuous.test.ts
//
// Continuous-end-to-end pinning contract for the Option C port lifecycle
// per .planning/debug/empty-archive-port-race.md step 3:
//
//   "Continuous end-to-end vitest covering 600 s of port lifecycle
//    (2 reconnects + simulated REQUEST_BUFFER round-trips). Becomes the
//    new pinning contract for the port lifecycle."
//
// The UAT Test 3 BLOCKER had two visible symptoms after recording for
// 5+ minutes (past the 290 s legacy pre-emptive reconnect mark):
//   (a) 3x Uncaught "Attempting to use a disconnected port object"
//   (b) Archive shipped with NO video/last_30sec.webm (silent data loss)
//
// This test simulates the same operator timeline at the unit-test level:
// - 600 s of "wall-clock" simulated by driving setInterval callbacks
//   directly (no real wait).
// - 2 forced port reconnects mid-stream (covering the legacy 290 s + a
//   second one at ~580 s — well within a realistic operator session).
// - REQUEST_BUFFER round-trips at points BEFORE and AFTER each reconnect
//   window.
//
// PASS criteria:
//   1. No Uncaught error escapes from the recorder's bootstrap or its
//      port lifecycle (H1 contract — protects against the offscreen
//      console's UAT 3x error pattern).
//   2. Every REQUEST_BUFFER round-trip resolves with success (H2
//      contract — protects against the silent empty-archive shipping).
//   3. PONGs flow back from SW (closes the health-probe loop).
//   4. BUFFER messages carry non-empty segments (no silent-drop path).

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

// vi.mock("jszip", ...) replaces the production JSZip module with a stub
// that round-trips file additions without actually generating a real zip.
// Production JSZip's generateAsync({type:'blob'}) accepts Node Blobs but
// fails on them in Node v22+ (the file's read pipeline does an
// instanceof Blob check that only matches the platform's native Blob, not
// the polyfilled one — observed: "Can't read the data of 'screenshot.png'.
// Is it in a supported JavaScript type ... ?"). The mock preserves the
// integration shape (zip.file() collects, zip.generateAsync() returns a
// Blob whose .size reflects total content) so saveArchive's success path
// is reachable without depending on JSZip's Node compatibility.
vi.mock('jszip', () => {
  class JSZipStub {
    private readonly entries: Map<string, { size: number }> = new Map();
    file(path: string, content: unknown): void {
      let size = 0;
      if (typeof content === 'string') {
        size = content.length;
      } else if (content instanceof Blob) {
        size = content.size;
      } else if (content instanceof Uint8Array) {
        size = content.byteLength;
      } else if (content instanceof ArrayBuffer) {
        size = content.byteLength;
      }
      this.entries.set(path, { size });
    }
    async generateAsync(_opts: { type: 'blob' }): Promise<Blob> {
      let total = 0;
      for (const { size } of this.entries.values()) {
        total += size;
      }
      return new Blob([new Uint8Array(total)], { type: 'application/zip' });
    }
  }
  return { default: JSZipStub };
});

// ─── Stubs ──────────────────────────────────────────────────────────────

interface OffPortStub {
  name: string;
  postMessage: ReturnType<typeof vi.fn>;
  onMessage: {
    addListener: ReturnType<typeof vi.fn>;
    _listeners: Array<(msg: unknown) => void>;
  };
  onDisconnect: {
    addListener: (fn: () => void) => void;
    _listeners: Array<() => void>;
  };
  disconnect: ReturnType<typeof vi.fn>;
  _disconnected: boolean;
}

interface SwPortStub {
  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>;
}

const ORIGINAL_SET_INTERVAL = globalThis.setInterval;
const ORIGINAL_CLEAR_INTERVAL = globalThis.clearInterval;
const ORIGINAL_SET_TIMEOUT = globalThis.setTimeout;
const ORIGINAL_CLEAR_TIMEOUT = globalThis.clearTimeout;

// Build a paired offscreen-port + SW-port that talk to each other through
// the captured onMessage listeners. The recorder.ts posts on its port
// reference (offPort); we route those messages into the SW-port's
// listeners. And vice-versa for SW -> offscreen.
//
// This is a deliberately-minimal harness: the goal is to surface the
// port lifecycle contract, not to replicate Chrome's full IPC fidelity.
function pairPorts(): { offPort: OffPortStub; swPort: SwPortStub } {
  // Forward declare to satisfy the postMessage closures.
  const offPort: OffPortStub = {
    name: 'video-keepalive',
    postMessage: vi.fn(),
    onMessage: { addListener: vi.fn(), _listeners: [] },
    onDisconnect: {
      addListener: (fn: () => void) => {
        offPort.onDisconnect._listeners.push(fn);
      },
      _listeners: [],
    },
    disconnect: vi.fn(() => {
      offPort._disconnected = true;
    }),
    _disconnected: false,
  };
  offPort.onMessage.addListener.mockImplementation(
    (fn: (msg: unknown) => void) => {
      offPort.onMessage._listeners.push(fn);
    }
  );

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

  // Wire postMessage AFTER both ports exist so each side can reach the
  // other's listeners.
  offPort.postMessage.mockImplementation((msg: unknown) => {
    if (offPort._disconnected) {
      throw new Error('Attempting to use a disconnected port object');
    }
    swPort.onMessage._listeners.forEach((fn) => fn(msg));
  });
  swPort.postMessage.mockImplementation((msg: unknown) => {
    offPort.onMessage._listeners.forEach((fn) => fn(msg));
  });

  return { offPort, swPort };
}

describe('port lifecycle — continuous 600 s simulation (Option C pinning)', () => {
  beforeEach(() => {
    vi.resetModules();
  });

  afterEach(() => {
    globalThis.setInterval = ORIGINAL_SET_INTERVAL;
    globalThis.clearInterval = ORIGINAL_CLEAR_INTERVAL;
    globalThis.setTimeout = ORIGINAL_SET_TIMEOUT;
    globalThis.clearTimeout = ORIGINAL_CLEAR_TIMEOUT;
  });

  it('600 s lifecycle with 2 reconnects + 3 REQUEST_BUFFER round-trips: no Uncaught, no silent loss', async () => {
    // Capture the offscreen recorder's ping interval callback so we can
    // drive it synchronously (no wall-clock waits).
    const intervalCallbacks: Array<() => void> = [];
    globalThis.setInterval = ((cb: () => void, _ms: number) => {
      intervalCallbacks.push(cb);
      return 999 as unknown as ReturnType<typeof setInterval>;
    }) as typeof globalThis.setInterval;
    globalThis.clearInterval = (() => {}) as typeof globalThis.clearInterval;

    // Shared registries for tracking ports across both SW + offscreen.
    const offPorts: OffPortStub[] = [];
    const swPorts: SwPortStub[] = [];
    const onConnectCbs: Array<(p: SwPortStub) => void> = [];
    const onMessageHandlers: Array<
      (
        msg: unknown,
        sender: { id?: string },
        sendResponse: (resp: unknown) => void,
      ) => boolean
    > = [];

    // ONE unified chrome stub that the SW and offscreen modules BOTH
    // observe (they share the same `globalThis.chrome`). The SW
    // registers via onConnect.addListener / onMessage.addListener; the
    // offscreen calls connect() — both routed through the same object.
    const sharedChromeStub = {
      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().mockImplementation(
            (
              cb: (
                msg: unknown,
                sender: { id?: string },
                sendResponse: (resp: unknown) => void,
              ) => boolean,
            ) => {
              onMessageHandlers.push(cb);
            },
          ),
        },
        onConnect: {
          addListener: (cb: (p: SwPortStub) => void) => {
            onConnectCbs.push(cb);
          },
        },
        onInstalled: { addListener: vi.fn() },
        connect: () => {
          const pair = pairPorts();
          offPorts.push(pair.offPort);
          swPorts.push(pair.swPort);
          // Fire the SW's onConnect handler in a microtask so the
          // offscreen's connect() returns first (matching Chrome's
          // async-but-effectively-immediate cross-context wiring).
          queueMicrotask(() => {
            onConnectCbs.forEach((cb) => cb(pair.swPort));
          });
          return pair.offPort;
        },
      },
      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),
      },
    };

    interface GlobalScope {
      chrome?: unknown;
      MediaRecorder?: { isTypeSupported: (mime: string) => boolean };
      indexedDB?: { deleteDatabase: ReturnType<typeof vi.fn> };
      fetch?: typeof fetch;
    }
    const g = globalThis as unknown as GlobalScope;
    g.chrome = sharedChromeStub;
    g.MediaRecorder = { isTypeSupported: vi.fn().mockReturnValue(true) };
    g.indexedDB = { deleteDatabase: vi.fn() };
    g.fetch = (async () =>
      ({
        blob: async () =>
          new Blob([new Uint8Array([0x89, 0x50])], { type: 'image/png' }),
      } as unknown as Response)) as unknown as typeof fetch;

    // Import SW first so its onConnect + onMessage listeners are
    // registered before the offscreen recorder calls connect().
    await import('../../src/background/index');
    expect(onConnectCbs.length).toBeGreaterThanOrEqual(1);
    expect(onMessageHandlers.length).toBeGreaterThanOrEqual(1);

    // Now the offscreen recorder. Its bootstrap calls
    // chrome.runtime.connect() → port 0 wired on both sides.
    const recorder = await import('../../src/offscreen/recorder');

    // Drain microtasks so the queueMicrotask-deferred SW onConnect fires.
    for (let i = 0; i < 8; i++) await Promise.resolve();

    expect(offPorts.length).toBe(1);
    expect(swPorts.length).toBe(1);

    // Seed the offscreen ring buffer with 3 segments so REQUEST_BUFFER
    // round-trips have data to ship.
    recorder.resetBuffer();
    const ebmlMagic = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3]);
    for (let i = 0; i < 3; i++) {
      const payload = new Uint8Array(1024);
      payload.set(ebmlMagic, 0);
      payload.fill(i + 0x10, ebmlMagic.length);
      recorder.pushSegmentForTest(new Blob([payload], { type: 'video/webm' }));
    }
    expect(recorder.getSegments().length).toBe(3);

    // Helper to dispatch SAVE_ARCHIVE through the SW's onMessage handler
    // and drain microtasks until the response settles.
    async function dispatchSaveArchive(): Promise<{
      success?: boolean;
      error?: unknown;
    }> {
      let resp: { success?: boolean; error?: unknown } | undefined;
      onMessageHandlers[0](
        { type: 'SAVE_ARCHIVE' },
        { id: 'ext-id-test' },
        (r) => {
          resp = r as { success?: boolean; error?: unknown };
        },
      );
      // Generous drain: SAVE_ARCHIVE chains several awaits (captureScreenshot,
      // getVideoBufferFromOffscreen, tabs.sendMessage, createArchive's
      // zip.generateAsync). JSZip in particular uses internal Promise
      // chains that need multiple macrotask flushes to settle.
      for (let phase = 0; phase < 5; phase++) {
        for (let i = 0; i < 64; i++) await Promise.resolve();
        await new Promise<void>((resolve) => setTimeout(resolve, 0));
      }
      if (resp === undefined) {
        throw new Error('SAVE_ARCHIVE did not respond within drain window');
      }
      return resp;
    }

    // ──────────────────────────────────────────────────────────────────
    // Stage 1 (T ≈ 0–289 s) — ping/pong cycles. PINGs reach SW; SW
    // echoes PONG; the offscreen's missedPongs counter stays at 0.
    // ──────────────────────────────────────────────────────────────────
    const pingCb = intervalCallbacks[0];
    expect(pingCb).toBeTruthy();

    // 12 ping cycles ≈ 300 s at PORT_PING_MS = 25_000 ms.
    for (let i = 0; i < 12; i++) {
      // The offscreen's ping callback should not throw on a healthy port.
      expect(() => pingCb()).not.toThrow();
    }
    // No new port should have been created — the health probe is happy.
    expect(offPorts.length).toBe(1);

    // ──────────────────────────────────────────────────────────────────
    // Stage 2 (T ≈ 290 s) — first REQUEST_BUFFER round-trip via SW
    // SAVE_ARCHIVE. The legacy code's 290 s pre-emptive reconnect would
    // have fired here; the Option C design has no such timer, so the
    // round-trip completes cleanly on the original port.
    // ──────────────────────────────────────────────────────────────────
    const save1 = await dispatchSaveArchive();
    expect(save1.success).toBe(true);

    // ──────────────────────────────────────────────────────────────────
    // Stage 3 (T ≈ 295 s) — simulate an EXTERNAL reconnect. The Option C
    // health probe doesn't fire one on its own (no PINGs missed), so we
    // force it via the path the H1.b test pins: external disconnect →
    // ping detects → reconnectPort initiates. Fresh port pair appears.
    // ──────────────────────────────────────────────────────────────────
    offPorts[0]._disconnected = true;
    expect(() => pingCb()).not.toThrow();

    // Drain microtasks for the reconnect to wire up port 1.
    for (let i = 0; i < 32; i++) await Promise.resolve();
    expect(offPorts.length).toBe(2);
    expect(swPorts.length).toBe(2);

    // ──────────────────────────────────────────────────────────────────
    // Stage 4 (T ≈ 296 s) — second REQUEST_BUFFER round-trip on the
    // FRESH port. This is the round-trip that the legacy code would have
    // failed silently (the SW's per-request listener was bound to the
    // OLD port). Under Option C the SW's pendingBufferRequests map is
    // port-agnostic — the BUFFER lands on whichever port the offscreen
    // posts on, and the requestId match resolves cleanly.
    // ──────────────────────────────────────────────────────────────────
    expect(recorder.getSegments().length).toBe(3);
    const save2 = await dispatchSaveArchive();
    expect(save2.success).toBe(true);

    // ──────────────────────────────────────────────────────────────────
    // Stage 5 (T ≈ 580 s) — second forced reconnect + third
    // REQUEST_BUFFER round-trip. Pins that the lifecycle remains stable
    // across multiple cycles (the UAT session observed 3 errors at
    // 290/315/340 s — recurring at PORT_PING_MS = 25_000 ms intervals
    // after the initial pre-emptive reconnect).
    // ──────────────────────────────────────────────────────────────────
    offPorts[1]._disconnected = true;
    expect(() => pingCb()).not.toThrow();
    for (let i = 0; i < 32; i++) await Promise.resolve();
    expect(offPorts.length).toBe(3);
    expect(swPorts.length).toBe(3);

    const save3 = await dispatchSaveArchive();
    expect(save3.success).toBe(true);

    // ──────────────────────────────────────────────────────────────────
    // Final assertions
    // ──────────────────────────────────────────────────────────────────

    // a) Every BUFFER posted (across all ports) carried segments — no
    //    silent-loss path was reachable in this simulation.
    let buffersWithSegments = 0;
    let buffersTotal = 0;
    for (const port of offPorts) {
      for (const call of port.postMessage.mock.calls) {
        const msg = call[0] as { type?: unknown; segments?: unknown };
        if (msg.type === 'BUFFER') {
          buffersTotal++;
          if (Array.isArray(msg.segments) && msg.segments.length > 0) {
            buffersWithSegments++;
          }
        }
      }
    }
    expect(buffersTotal).toBeGreaterThanOrEqual(3);
    // EVERY buffer that crossed the wire carried segments.
    expect(buffersWithSegments).toBe(buffersTotal);

    // b) PONGs round-tripped — the SW echoed at least one PONG (proving
    //    the health-probe loop closes).
    let pongCount = 0;
    for (const port of swPorts) {
      for (const call of port.postMessage.mock.calls) {
        const msg = call[0] as { type?: unknown };
        if (msg.type === 'PONG') {
          pongCount++;
        }
      }
    }
    expect(pongCount).toBeGreaterThanOrEqual(1);

    recorder.resetBuffer();
  });
});
