// tests/background/save-archive-does-not-stop-recording.test.ts
//
// Plan 01-09 Amendment 3 (2026-05-19) — charter reversal of the
// prior `save-archive-stops-recording.test.ts` (renamed via `git mv`
// from this same file; history preserved). Debug session record:
// `.planning/debug/resolved/01-09-save-does-not-stop-recording.md`.
//
// Contract (REVERSED 2026-05-19): SAVE_ARCHIVE creates a new zip but
// does NOT stop the recorder. The recording is continuous — the only
// termination paths are:
//   1. Operator clicks Chrome's "Stop sharing" banner → offscreen's
//      onUserStoppedSharing emits RECORDING_ERROR{error:'user-stopped-sharing'}
//      → SW's RECORDING_ERROR handler routes to setIdleMode (Bug B
//      branch — preserved). Tests in `tests/background/badge-state-machine.test.ts`
//      pin this path; A14 harness assertion verifies it end-to-end.
//   2. Browser closes (SW terminates; offscreen tears down with it).
//   3. Extension uninstalled.
//
// SPEC alignment: `Тз расширение фаза1.md` framing is "continuous
// capture / always-on safety net". Operator workflow: hit a bug → SAVE
// (zip lands) → keep working → next bug also captured. The prior
// Amendment 2 fix at commits cd83eb0+4f4c3e2+2b6c24b+89f3337 implemented
// SAVE=stop semantics; 2026-05-19 operator UX iteration REVERSED that.
//
// Tests A..D below INVERT the prior contract: after SAVE_ARCHIVE, the
// SW state machine MUST remain in REC (badge stays 'REC', popup stays
// pinned to popup.html, no STOP_RECORDING dispatched to offscreen, no
// recovery notification fired).
//
// Test architecture (unchanged from the prior file): synthetic BUFFER
// response delivered via port.onMessage listeners to drive saveArchive's
// request-id'd buffer fetch to completion. The empty-segments BUFFER
// causes createArchive to throw EmptyVideoBufferError (intentional —
// the post-save state invariance MUST hold on BOTH the success path
// AND the empty-buffer-error path: SAVE click does NOT stop recording,
// success or empty-buffer alike).

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: (r: unknown) => void): boolean;
}
interface OnClickedCallback { (info?: unknown): void | Promise<void>; }
interface OnNotificationClickedCallback { (notificationId: string): 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> };
  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: (cb: OnNotificationClickedCallback) => void;
      _callbacks: OnNotificationClickedCallback[];
    };
  };
}

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(1) },
    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: (cb) => stub.notifications.onClicked._callbacks.push(cb),
        _callbacks: [],
      },
    },
  };
  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.
// Mirrors `tests/background/request-id-protocol.test.ts:stubFetch` —
// captureScreenshot does `await fetch(dataUrl).blob()`.
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;
}

// Helper: filter setBadgeText calls by the text argument value.
function badgeTextCallsFor(stub: BgChromeStub, text: string): unknown[][] {
  return stub.action.setBadgeText.mock.calls.filter(
    (args: unknown[]) => {
      const opts = args[0] as { text?: unknown };
      return typeof opts === 'object' && opts !== null && opts.text === text;
    },
  );
}

// Helper: filter setPopup calls by popup-arg value.
function setPopupCallsFor(stub: BgChromeStub, popupArg: string): unknown[][] {
  return stub.action.setPopup.mock.calls.filter(
    (args: unknown[]) => {
      const opts = args[0] as { popup?: unknown };
      return typeof opts === 'object' && opts !== null && opts.popup === popupArg;
    },
  );
}

// Helper: filter chrome.runtime.sendMessage calls by message.type.
function sendMessageCallsForType(stub: BgChromeStub, type: string): unknown[][] {
  return stub.runtime.sendMessage.mock.calls.filter(
    (args: unknown[]) => {
      const msg = args[0] as { type?: unknown };
      return typeof msg === 'object' && msg !== null && msg.type === type;
    },
  );
}

// Helper: filter chrome.notifications.create calls whose first argument
// (the notification id) starts with `prefix`.
function notificationCreateCallsWithIdPrefix(stub: BgChromeStub, prefix: string): unknown[][] {
  return stub.notifications.create.mock.calls.filter(
    (args: unknown[]) => {
      const id = args[0];
      return typeof id === 'string' && id.startsWith(prefix);
    },
  );
}

/**
 * Setup helper — loads the SW module, wires a synthetic port, signals
 * offscreen ready, then starts a recording via toolbar onClicked so the
 * SW is in REC state when the test dispatches SAVE_ARCHIVE.
 *
 * Returns the port (so tests can resolve the in-flight REQUEST_BUFFER
 * with a synthetic BUFFER response).
 */
async function setupSwInRecState(stub: BgChromeStub): Promise<PortStub> {
  (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);
  }

  // Signal offscreen ready so startVideoCapture's awaitOffscreenReady resolves.
  const onMsgHandler = stub.runtime.onMessage._callbacks[0];
  onMsgHandler({ type: 'OFFSCREEN_READY' }, { id: 'ext-id-test' }, vi.fn());
  for (let i = 0; i < 16; i++) await Promise.resolve();

  // Trigger toolbar click → startVideoCapture → isRecording=true + badge=REC.
  const onClickedCb = stub.action.onClicked._callbacks[0];
  await onClickedCb();
  for (let i = 0; i < 32; i++) await Promise.resolve();

  return port;
}

/**
 * Dispatch SAVE_ARCHIVE and drain the resulting async chain to completion.
 * Resolves the in-flight REQUEST_BUFFER with an empty BUFFER (which causes
 * createArchive → EmptyVideoBufferError → saveArchive catch branch — under
 * the REVERSED contract, neither the success path nor the catch path may
 * stop the recording).
 *
 * @param stub - The chrome stub.
 * @param port - The wired offscreen port.
 * @returns The saveArchive sendResponse value (typically `{success:false, error:...}`
 *          under the empty-buffer branch).
 */
async function dispatchSaveArchiveAndDrain(
  stub: BgChromeStub,
  port: PortStub,
): Promise<unknown> {
  let saveResp: unknown = undefined;
  const onMsgHandler = stub.runtime.onMessage._callbacks[0];
  onMsgHandler(
    { type: 'SAVE_ARCHIVE' },
    { id: 'ext-id-test' },
    (r: unknown) => {
      saveResp = r;
    },
  );

  // Drain microtasks so saveArchive reaches getVideoBufferFromOffscreen
  // and posts REQUEST_BUFFER on the port.
  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();

  // Find the REQUEST_BUFFER post and respond with an empty-segments BUFFER.
  // createArchive throws EmptyVideoBufferError when segments.length === 0,
  // the catch branch runs (broadcasts RECORDING_ERROR), and the REVERSED
  // contract requires that no STOP_RECORDING/setIdleMode happen at all.
  const requestBufferCalls = port.postMessage.mock.calls.filter(
    (c: unknown[]) =>
      typeof c[0] === 'object' &&
      c[0] !== null &&
      (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER',
  );
  if (requestBufferCalls.length > 0) {
    const reqMsg = requestBufferCalls[0][0] as { requestId?: unknown };
    port.onMessage._listeners.forEach((fn) =>
      fn({
        type: 'BUFFER',
        requestId: reqMsg.requestId,
        segments: [],
      }),
    );
  }

  // Drain again so the BUFFER response resolves through the SW's
  // pendingBufferRequests map → saveArchive resumes → throws
  // EmptyVideoBufferError → catch branch runs to completion. Under the
  // REVERSED contract there is no finally block, so the state machine
  // remains in REC (badge='REC', popup pinned, isRecording=true).
  for (let i = 0; i < 64; i++) await Promise.resolve();
  await new Promise<void>((resolve) => setTimeout(resolve, 0));
  for (let i = 0; i < 32; i++) await Promise.resolve();

  return saveResp;
}

describe('Plan 01-09 Amendment 3 (debug 01-09-save-does-not-stop-recording): SAVE_ARCHIVE does NOT stop recording', () => {
  beforeEach(() => {
    vi.resetModules();
  });

  // ──────────────────────────────────────────────────────────────────────
  // Test A — after SAVE_ARCHIVE, the badge stays REC (no new '' painted).
  //
  // RED today (between the rename + the SW patch) because saveArchive()'s
  // `finally` block (introduced by Amendment 2, commit 4f4c3e2) still
  // calls setIdleMode() which dispatches setBadgeText({text:''}).
  //
  // GREEN after Amendment 3's revert: the `finally` block is removed;
  // saveArchive returns to "create zip → download → done"; no new badge
  // transition occurs; the badge remains 'REC' from setupSwInRecState.
  // ──────────────────────────────────────────────────────────────────────
  it('A: SAVE_ARCHIVE does NOT trigger setBadgeText({text:""}) — recording continues after save', async () => {
    const stub = buildBgStub();
    const port = await setupSwInRecState(stub);

    // Snapshot baseline AFTER setup — setIdleMode at SW init painted ''
    // once, then setRecordingMode painted 'REC'. Under the REVERSED
    // contract there is NO additional '' transition after SAVE.
    const offBefore = badgeTextCallsFor(stub, '').length;

    await dispatchSaveArchiveAndDrain(stub, port);

    const offAfter = badgeTextCallsFor(stub, '').length;
    expect(offAfter - offBefore).toBe(0);
  });

  // ──────────────────────────────────────────────────────────────────────
  // Test B — after SAVE_ARCHIVE, the popup is NOT emptied (stays pinned
  // to src/popup/index.html so the SAVE popup remains accessible for
  // the next operator gesture and onClicked stays inert).
  //
  // RED today (Amendment 2's finally block calls setIdleMode which calls
  // setPopup({popup:''})).
  //
  // GREEN after Amendment 3's revert: no setPopup({popup:''}) call;
  // popup stays at POPUP_HTML_PATH (set by setRecordingMode during
  // setupSwInRecState).
  // ──────────────────────────────────────────────────────────────────────
  it('B: SAVE_ARCHIVE does NOT trigger setPopup({popup:""}) — popup stays pinned to popup.html', async () => {
    const stub = buildBgStub();
    const port = await setupSwInRecState(stub);

    const popupIdleBefore = setPopupCallsFor(stub, '').length;

    await dispatchSaveArchiveAndDrain(stub, port);

    const popupIdleAfter = setPopupCallsFor(stub, '').length;
    expect(popupIdleAfter - popupIdleBefore).toBe(0);
  });

  // ──────────────────────────────────────────────────────────────────────
  // Test C — after SAVE_ARCHIVE, SW does NOT dispatch STOP_RECORDING to
  // offscreen. Regression guard against accidental re-introduction of
  // the Amendment 2 finally block (or an equivalent dispatch elsewhere).
  //
  // RED today (Amendment 2's `chrome.runtime.sendMessage({type:'STOP_RECORDING'})`
  // in the finally block still runs).
  //
  // GREEN after Amendment 3's revert: no STOP_RECORDING dispatch from
  // saveArchive. The offscreen MediaRecorder + MediaStream remain live;
  // Chrome's screen-sharing banner stays open; the next chunk continues
  // to land in the offscreen ring buffer.
  // ──────────────────────────────────────────────────────────────────────
  it('C: SAVE_ARCHIVE does NOT dispatch STOP_RECORDING via chrome.runtime.sendMessage', async () => {
    const stub = buildBgStub();
    const port = await setupSwInRecState(stub);

    const stopBefore = sendMessageCallsForType(stub, 'STOP_RECORDING').length;

    await dispatchSaveArchiveAndDrain(stub, port);

    const stopAfter = sendMessageCallsForType(stub, 'STOP_RECORDING').length;
    expect(stopAfter - stopBefore).toBe(0);
  });

  // ──────────────────────────────────────────────────────────────────────
  // Test D — after SAVE_ARCHIVE, NO recovery notification fires directly
  // from saveArchive. Preserved as regression guard from the prior
  // contract (Amendment 2) — under either charter, saveArchive itself
  // does not create recovery notifications. The RECORDING_ERROR self-
  // dispatch on the empty-buffer catch path goes through the SW's
  // onMessage handler, not the chrome.notifications API directly.
  //
  // Under the synthetic test harness, chrome.runtime.sendMessage is a
  // vi.fn().mockResolvedValue(undefined) — it does NOT loop back into
  // the onMessage handler (no test-side bus). So even the
  // RECORDING_ERROR self-dispatch doesn't create the notification here.
  // The test remains GREEN — a regression guard against any future
  // code path that would directly create a recovery notification
  // inside saveArchive itself.
  // ──────────────────────────────────────────────────────────────────────
  it('D: SAVE_ARCHIVE does NOT fire a mokosh-recovery-* notification', async () => {
    const stub = buildBgStub();
    const port = await setupSwInRecState(stub);

    const recoveryBefore = notificationCreateCallsWithIdPrefix(stub, 'mokosh-recovery-').length;

    await dispatchSaveArchiveAndDrain(stub, port);

    const recoveryAfter = notificationCreateCallsWithIdPrefix(stub, 'mokosh-recovery-').length;
    expect(recoveryAfter - recoveryBefore).toBe(0);
  });
});
