// tests/background/badge-state-machine.test.ts
//
// Plan 01-09 Task 3 RED — pins the 3-state badge contract:
// REC (green, 'REC', recording title)
// OFF (red, '', idle title)
// ERROR (yellow, 'ERR', error title)
//
// Plus Test D: receiving RECORDING_ERROR via onMessage triggers
// setBadgeState('ERROR') (asserted via setBadgeText spy receiving 'ERR').
//
// Bug B fix (debug session 01-09-recovery-flow) — Tests E + F:
// `RECORDING_ERROR{error:'user-stopped-sharing'}` must route through
// setIdleMode (badge OFF, popup empties) instead of setErrorMode, because
// the operator-initiated stop is NOT an error — leaving popup in SAVE-only
// + badge in ERROR locks the operator out of restart (chrome.action.onClicked
// is gated on popup absence; SAVE-only popup wins the click forever).
// Other error codes preserve setErrorMode routing (defensive fallback for
// genuine capture failures: codec-unsupported, wrong-display-surface, etc.).

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

interface OnConnectCallback { (port: unknown): 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> };
}

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,xx'),
    },
    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;
}

// 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 setBadgeBackgroundColor calls by hex color (case-insensitive).
function badgeColorCallsFor(stub: BgChromeStub, hexPattern: RegExp): unknown[][] {
  return stub.action.setBadgeBackgroundColor.mock.calls.filter(
    (args: unknown[]) => {
      const opts = args[0] as { color?: unknown };
      const color = typeof opts === 'object' && opts !== null ? opts.color : undefined;
      return typeof color === 'string' && hexPattern.test(color);
    },
  );
}

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

describe('Plan 01-09 Task 3: badge state machine REC/OFF/ERROR contract', () => {
  beforeEach(() => {
    vi.resetModules();
  });

  // ──────────────────────────────────────────────────────────────────────
  // Test A — REC state: setBadgeText('REC') + setBadgeBackgroundColor(green) + setTitle
  //
  // Triggered by chrome.action.onClicked firing startVideoCapture
  // successfully (which calls setRecordingMode → setBadgeState('REC')).
  // ──────────────────────────────────────────────────────────────────────
  it('A: setBadgeState("REC") sets text="REC" + green background + recording title', async () => {
    const stub = buildBgStub();
    (globalThis as unknown as GlobalWithBgChrome).chrome = stub;
    (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() };

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

    // Connect a port + signal offscreen ready so startVideoCapture completes.
    const port = {
      name: 'video-keepalive',
      sender: { id: 'ext-id-test' },
      postMessage: vi.fn(),
      onMessage: { addListener: vi.fn() },
      onDisconnect: { addListener: vi.fn() },
      disconnect: vi.fn(),
    };
    if (stub.runtime.onConnect._callbacks.length > 0) {
      stub.runtime.onConnect._callbacks[0](port);
    }
    const onMsgHandler = stub.runtime.onMessage._callbacks[0];
    onMsgHandler({ type: 'OFFSCREEN_READY' }, { id: 'ext-id-test' }, vi.fn());

    const cb = stub.action.onClicked._callbacks[0];
    cb();
    for (let i = 0; i < 32; i++) await Promise.resolve();

    // setBadgeText was called with text='REC'.
    expect(badgeTextCallsFor(stub, 'REC').length).toBeGreaterThanOrEqual(1);
    // Plan 01-12 Wave 4: BADGE_REC_COLOR migrated from #00C853 (material
    // green) to #b2543d (--mks-madder-600 per D-04 loom palette). The
    // assertion below is updated lockstep.
    const recColorCalls = stub.action.setBadgeBackgroundColor.mock.calls.filter(
      (args: unknown[]) => {
        const opts = args[0] as { color?: unknown };
        const color = typeof opts === 'object' && opts !== null ? opts.color : undefined;
        return typeof color === 'string' && /^#b2543d$/i.test(color);
      },
    );
    expect(recColorCalls.length).toBeGreaterThanOrEqual(1);
    // setTitle was called with the recording title. Plan 01-12 Wave 4:
    // setBadgeState now reads chrome.i18n.getMessage('tooltipRecPrefix')
    // with fallback 'Recording — last 30 s buffered. Click to save.'.
    // Tests without a chrome.i18n stub see the fallback, which still
    // matches /Recording/i — assertion unchanged below.
    const titleCalls = stub.action.setTitle.mock.calls.filter(
      (args: unknown[]) => {
        const opts = args[0] as { title?: unknown };
        const title = typeof opts === 'object' && opts !== null ? opts.title : undefined;
        return typeof title === 'string' && /Recording|recording/i.test(title);
      },
    );
    expect(titleCalls.length).toBeGreaterThanOrEqual(1);
  });

  // ──────────────────────────────────────────────────────────────────────
  // Test B — OFF state on SW init (initialize → setIdleMode → setBadgeState('OFF'))
  // ──────────────────────────────────────────────────────────────────────
  it('B: setBadgeState("OFF") sets text="" + red background + idle title (called at init)', async () => {
    const stub = buildBgStub();
    (globalThis as unknown as GlobalWithBgChrome).chrome = stub;
    (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() };

    await import('../../src/background/index');
    // Drain so initialize completes.
    for (let i = 0; i < 16; i++) await Promise.resolve();

    // OFF text is empty string.
    expect(badgeTextCallsFor(stub, '').length).toBeGreaterThanOrEqual(1);
    const redCalls = stub.action.setBadgeBackgroundColor.mock.calls.filter(
      (args: unknown[]) => {
        const opts = args[0] as { color?: unknown };
        const color = typeof opts === 'object' && opts !== null ? opts.color : undefined;
        return typeof color === 'string' && /^#D32F2F$/i.test(color);
      },
    );
    expect(redCalls.length).toBeGreaterThanOrEqual(1);
    const titleCalls = stub.action.setTitle.mock.calls.filter(
      (args: unknown[]) => {
        const opts = args[0] as { title?: unknown };
        const title = typeof opts === 'object' && opts !== null ? opts.title : undefined;
        return typeof title === 'string' && /Not recording/i.test(title);
      },
    );
    expect(titleCalls.length).toBeGreaterThanOrEqual(1);
  });

  // ──────────────────────────────────────────────────────────────────────
  // Test C — ERROR state: setBadgeText('ERR') + yellow background + error title.
  // ──────────────────────────────────────────────────────────────────────
  it('C: setBadgeState("ERROR") sets text="ERR" + yellow background + error title', async () => {
    const stub = buildBgStub();
    (globalThis as unknown as GlobalWithBgChrome).chrome = stub;
    (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() };

    await import('../../src/background/index');
    for (let i = 0; i < 8; i++) await Promise.resolve();

    // Trigger ERROR via RECORDING_ERROR onMessage path.
    const onMsgHandler = stub.runtime.onMessage._callbacks[0];
    onMsgHandler(
      { type: 'RECORDING_ERROR', error: 'codec-unsupported' },
      { id: 'ext-id-test' },
      vi.fn(),
    );
    for (let i = 0; i < 16; i++) await Promise.resolve();

    expect(badgeTextCallsFor(stub, 'ERR').length).toBeGreaterThanOrEqual(1);
    const yellowCalls = stub.action.setBadgeBackgroundColor.mock.calls.filter(
      (args: unknown[]) => {
        const opts = args[0] as { color?: unknown };
        const color = typeof opts === 'object' && opts !== null ? opts.color : undefined;
        return typeof color === 'string' && /^#F9A825$/i.test(color);
      },
    );
    expect(yellowCalls.length).toBeGreaterThanOrEqual(1);
    const titleCalls = stub.action.setTitle.mock.calls.filter(
      (args: unknown[]) => {
        const opts = args[0] as { title?: unknown };
        const title = typeof opts === 'object' && opts !== null ? opts.title : undefined;
        return typeof title === 'string' && /error/i.test(title);
      },
    );
    expect(titleCalls.length).toBeGreaterThanOrEqual(1);
  });

  // ──────────────────────────────────────────────────────────────────────
  // Test D — RECORDING_ERROR via onMessage triggers ERROR state (already
  // covered by Test C but pinned here as a separate dispatch assertion).
  // ──────────────────────────────────────────────────────────────────────
  it('D: RECORDING_ERROR onMessage triggers setBadgeText("ERR") within a microtask', async () => {
    const stub = buildBgStub();
    (globalThis as unknown as GlobalWithBgChrome).chrome = stub;
    (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() };

    await import('../../src/background/index');
    for (let i = 0; i < 8; i++) await Promise.resolve();

    // Snapshot call count before — only the init OFF call should have fired.
    const errCallsBefore = badgeTextCallsFor(stub, 'ERR').length;

    const onMsgHandler = stub.runtime.onMessage._callbacks[0];
    onMsgHandler(
      { type: 'RECORDING_ERROR', error: 'capture-failed' },
      { id: 'ext-id-test' },
      vi.fn(),
    );
    for (let i = 0; i < 16; i++) await Promise.resolve();

    const errCallsAfter = badgeTextCallsFor(stub, 'ERR').length;
    expect(errCallsAfter - errCallsBefore).toBeGreaterThanOrEqual(1);
  });

  // ──────────────────────────────────────────────────────────────────────
  // Test E — Bug B (debug 01-09-recovery-flow) — operator-initiated
  // stop-sharing routes to IDLE, NOT to ERROR.
  //
  // Contract: when the operator clicks Chrome's "Stop sharing" banner
  // mid-recording, the offscreen recorder broadcasts
  //   RECORDING_ERROR{ error: 'user-stopped-sharing' }
  // (src/offscreen/recorder.ts: onUserStoppedSharing emits this exact
  // payload after resetBuffer + track release).
  //
  // The SW MUST route this through setIdleMode (badge OFF + popup '')
  // rather than setErrorMode, because:
  //   1. operator-initiated stop is not an error condition;
  //   2. setErrorMode leaves the popup pointing at src/popup/index.html
  //      (SAVE-only), so toolbar.onClicked CANNOT re-fire — the popup
  //      wins the click and the operator has no restart path;
  //   3. resetBuffer has already cleared the offscreen buffer, so SAVE
  //      mode is meaningless (nothing to save).
  //
  // Assertions on the post-dispatch state machine:
  //   - setBadgeText was called with '' (OFF text), NOT with 'ERR' (after
  //     the snapshot baseline at init).
  //   - setBadgeBackgroundColor was called with the OFF/red palette
  //     (#D32F2F), NOT with the ERROR/yellow palette (#F9A825).
  //   - setPopup was called with '' (idle popup) so the next toolbar
  //     click fires chrome.action.onClicked (the restart path).
  //
  // RED today because src/background/index.ts:725 routes ALL error codes
  // through setErrorMode unconditionally.
  // ──────────────────────────────────────────────────────────────────────
  it('E: RECORDING_ERROR{error:"user-stopped-sharing"} routes to IDLE — badge OFF, popup empties', async () => {
    const stub = buildBgStub();
    (globalThis as unknown as GlobalWithBgChrome).chrome = stub;
    (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() };

    await import('../../src/background/index');
    for (let i = 0; i < 8; i++) await Promise.resolve();

    // Snapshot baseline AFTER init (which paints OFF). Bug B assertions
    // are about the DELTA introduced by the user-stopped-sharing dispatch.
    const offTextBefore = badgeTextCallsFor(stub, '').length;
    const errTextBefore = badgeTextCallsFor(stub, 'ERR').length;
    const redColorBefore = badgeColorCallsFor(stub, /^#D32F2F$/i).length;
    const yellowColorBefore = badgeColorCallsFor(stub, /^#F9A825$/i).length;
    const popupIdleBefore = setPopupCallsFor(stub, '').length;
    const popupHtmlBefore = setPopupCallsFor(stub, 'src/popup/index.html').length;

    // Dispatch the operator-stopped-sharing payload that the offscreen
    // recorder emits via onUserStoppedSharing.
    const onMsgHandler = stub.runtime.onMessage._callbacks[0];
    onMsgHandler(
      { type: 'RECORDING_ERROR', error: 'user-stopped-sharing' },
      { id: 'ext-id-test' },
      vi.fn(),
    );
    for (let i = 0; i < 16; i++) await Promise.resolve();

    const offTextAfter = badgeTextCallsFor(stub, '').length;
    const errTextAfter = badgeTextCallsFor(stub, 'ERR').length;
    const redColorAfter = badgeColorCallsFor(stub, /^#D32F2F$/i).length;
    const yellowColorAfter = badgeColorCallsFor(stub, /^#F9A825$/i).length;
    const popupIdleAfter = setPopupCallsFor(stub, '').length;
    const popupHtmlAfter = setPopupCallsFor(stub, 'src/popup/index.html').length;

    // Positive: an OFF transition fired (empty badge text + red color +
    // popup empties).
    expect(offTextAfter - offTextBefore).toBeGreaterThanOrEqual(1);
    expect(redColorAfter - redColorBefore).toBeGreaterThanOrEqual(1);
    expect(popupIdleAfter - popupIdleBefore).toBeGreaterThanOrEqual(1);

    // Negative: NO ERROR transition fired (no 'ERR' badge text, no
    // yellow color, no popup-html re-set).
    expect(errTextAfter - errTextBefore).toBe(0);
    expect(yellowColorAfter - yellowColorBefore).toBe(0);
    expect(popupHtmlAfter - popupHtmlBefore).toBe(0);
  });

  // ──────────────────────────────────────────────────────────────────────
  // Test F — Bug B preservation guard: any RECORDING_ERROR whose code is
  // NOT 'user-stopped-sharing' continues to route through setErrorMode.
  //
  // This is the defensive-fallback half of the conditional routing patch
  // — it pins the contract that genuine capture failures (codec-
  // unsupported, wrong-display-surface, capture-failed, permission-denied,
  // empty-video-buffer, unknown, etc.) still raise the badge to ERROR
  // (yellow 'ERR'). Without this test the fix could over-rotate (route
  // ALL codes to IDLE) and silently lose the operator-facing error
  // surface for genuine failures.
  //
  // Uses 'codec-unsupported' as the representative non-stop code (a
  // realistic capture failure from src/offscreen/recorder.ts
  // CaptureErrorCode union, exercised in tests/offscreen/codec-check.test.ts).
  //
  // GREEN today (the current handler unconditionally raises ERROR), and
  // MUST remain GREEN after the Bug B patch — this is the regression
  // pin proving the fix didn't over-rotate.
  // ──────────────────────────────────────────────────────────────────────
  it('F: RECORDING_ERROR{error:"codec-unsupported"} still routes to ERROR — badge ERR + yellow (preserved)', async () => {
    const stub = buildBgStub();
    (globalThis as unknown as GlobalWithBgChrome).chrome = stub;
    (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() };

    await import('../../src/background/index');
    for (let i = 0; i < 8; i++) await Promise.resolve();

    // Snapshot baseline AFTER init.
    const errTextBefore = badgeTextCallsFor(stub, 'ERR').length;
    const yellowColorBefore = badgeColorCallsFor(stub, /^#F9A825$/i).length;
    const popupHtmlBefore = setPopupCallsFor(stub, 'src/popup/index.html').length;

    const onMsgHandler = stub.runtime.onMessage._callbacks[0];
    onMsgHandler(
      { type: 'RECORDING_ERROR', error: 'codec-unsupported' },
      { id: 'ext-id-test' },
      vi.fn(),
    );
    for (let i = 0; i < 16; i++) await Promise.resolve();

    const errTextAfter = badgeTextCallsFor(stub, 'ERR').length;
    const yellowColorAfter = badgeColorCallsFor(stub, /^#F9A825$/i).length;
    const popupHtmlAfter = setPopupCallsFor(stub, 'src/popup/index.html').length;

    expect(errTextAfter - errTextBefore).toBeGreaterThanOrEqual(1);
    expect(yellowColorAfter - yellowColorBefore).toBeGreaterThanOrEqual(1);
    expect(popupHtmlAfter - popupHtmlBefore).toBeGreaterThanOrEqual(1);
  });
});
