// src/offscreen/recorder.ts — Phase 01 source of truth.
// Owns: getDisplayMedia capture, MediaRecorder restart-segments lifecycle
// (D-13), in-memory segment ring buffer, codec strict-mode, track.onended
// cleanup, long-lived port keepalive (D-17), and the OFFSCREEN_READY
// handshake (Pattern 4).
//
// Architecture note (D-13 activation, debug session webm-playback-freeze):
// the recorder rotates segments — stop+restart the MediaRecorder every
// SEGMENT_DURATION_MS on the SAME MediaStream. Each segment is therefore
// a self-contained WebM with its own EBML header and seed keyframe,
// eliminating the orphan-keyframe gap that froze playback at ~1 s under
// the prior single-continuous-recorder + age-trim approach (D-09..D-11,
// retired). Trade-off: ~50–200 ms recording gap at each rotation boundary
// (accepted per CONTEXT.md D-13; the operator-facing "last 30 s" promise
// is preserved as 3 × 10 s = 30 s.)

import { OffscreenLogger } from '../shared/logger';
import { blobToBase64, base64ToBlob } from '../shared/binary';
import type { Message, TransferredVideoSegment } from '../shared/types';

// ─── Plan 01-11: gated test-hook dynamic import ───────────────────────
// MUST run at top-of-module so `globalThis.__mokoshTest` is installed
// before any code that the harness queries (assertion 3 reads
// displaySurface; assertion 6 dispatches 'ended'; assertion 11 reads
// segment count). The `__MOKOSH_UAT__` token is text-replaced by Vite
// at build time — `false` in production (vite.config.ts), `true` only
// in the test bundle (vite.test.config.ts). The `if (false)` branch
// is a static dead branch and Rollup tree-shakes the entire `await
// import` + dependent statements away (Plan 01-11 RESEARCH §6, refined:
// we use a dedicated token rather than `import.meta.env.MODE === 'test'`
// because vitest also runs with MODE='test' and gating on MODE would
// activate the hooks during unit tests + clobber the vi.fn() mocks).
//
// Tier-1 grep gate `tests/background/no-test-hooks-in-prod-bundle.test.ts`
// enforces the resulting absence of `__mokoshTest` / `setCurrentStream`
// / `setSegmentCountGetter` in every file under `dist/` post-build
// (T-1-11-01 — Elevation of Privilege via leaked hook surface).
//
// Module-scoped reference captured here so wire-points later in the
// file (the setCurrentStream call after `mediaStream = stream`; the
// setSegmentCountGetter call once `segments` is in scope) do not need
// to re-import per call. The Vite/Rollup dead-branch elimination treats
// the entire if-block as unreachable in production, so the `let` itself
// vanishes alongside the imports.
let testHooks: typeof import('../test-hooks/offscreen-hooks') | null = null;
if (__MOKOSH_UAT__) {
  testHooks = await import('../test-hooks/offscreen-hooks');
}

// ─── Константы (per CON-video-codec, CON-video-window, D-13) ────────────
// Длительность одного сегмента — 10 с (D-13, RESEARCH.md Pattern 3).
export const SEGMENT_DURATION_MS = 10_000;
// Сколько сегментов держим в памяти — 3 × 10 с = 30 с (CON-video-window).
export const MAX_SEGMENTS = 3;
// Окно «последних 30 секунд» в миллисекундах — выводимая константа.
// Сохранена под старым именем для обратной совместимости с тестами и
// для self-документации (см. segment-rotation.test.ts инвариант).
export const VIDEO_BUFFER_DURATION_MS = MAX_SEGMENTS * SEGMENT_DURATION_MS;

const VIDEO_MIME = 'video/webm;codecs=vp9';        // D-20 strict — no fallback
const VIDEO_BITRATE = 400_000;                     // CON-video-codec
// На втором уровне MediaRecorder.start() мы НЕ передаём timeslice —
// дождёмся финального ondataavailable из onstop(). Если потребуется
// промежуточная диагностика, можно вернуть таймслайс позже, не меняя
// контракт ротации.
const PORT_NAME = 'video-keepalive';               // long-lived port name (D-17, Pattern 5)
const PORT_PING_MS = 25_000;                       // < 30 s SW idle threshold
// Option C health-probe: how many consecutive PINGs may go unanswered
// (no PONG echo) before we declare the port dead and reconnect. With
// PORT_PING_MS = 25_000 ms, 3 missed PONGs = 75 s of unresponsive SW —
// well past the ~30 s Chrome SW idle threshold, so a real disconnect
// already would have fired its onDisconnect handler by then. This
// threshold catches the failure mode the old 290 s pre-emptive setTimeout
// pretended to: the SW is up but the port has gone half-open.
// See .planning/debug/empty-archive-port-race.md (Fix Strategy: Option C).
const MAX_MISSED_PONGS = 3;

const logger = new OffscreenLogger('Recorder');

// ─── Состояние модуля ───────────────────────────────────────────────────
// videoRecorder — текущий MediaRecorder; пересоздаётся при ротации.
// mediaStream — общий захватываемый поток (НЕ пересоздаётся при ротации,
//   иначе getDisplayMedia затребует новый user gesture, что недопустимо).
// segments — завершённые сегменты в порядке возраста (старшие первыми).
// currentChunks — буфер dataavailable-чанков для текущего сегмента;
//   сливается в один Blob по onstop и пушится в `segments`.
// rotationTimerId — setTimeout-хэндл следующей ротации; должен быть
//   очищен при STOP_RECORDING / onUserStoppedSharing.
let videoRecorder: MediaRecorder | null = null;
let mediaStream: MediaStream | null = null;
let segments: Blob[] = [];
let currentChunks: Blob[] = [];
let rotationTimerId: ReturnType<typeof setTimeout> | null = null;
let keepalivePort: chrome.runtime.Port | null = null;     // long-lived SW keepalive (D-17, Pattern 5)
// WR-03 fix: strictly-monotonic per-process counter for segment timestamps.
// Replaces the previous `Date.now() + idx` scheme, which could collide
// across two REQUEST_BUFFER calls within the same millisecond. The SW-side
// `mergeVideoSegments` sorts by this `timestamp` ascending; a pure counter
// guarantees deterministic ordering with zero wall-clock dependency.
let segmentSeq = 0;

// ─── Сегментный буфер (pure functions — testable in Node) ───────────────

/**
 * Возвращает копию массива завершённых сегментов в порядке от старого
 * к новому. Копия — чтобы вызывающий код (например, SW-side encode)
 * не мог случайно повредить внутреннее состояние.
 */
export function getSegments(): Blob[] {
  return [...segments];
}

/**
 * Тестовый seam (D-13): позволяет vitest-у драйвить ротацию
 * детерминистически, не инстанцируя реальный MediaRecorder (jsdom не
 * поставляет capture pipeline). Производственный путь — onstop → blob
 * → push — приходит через MediaRecorder.onstop в `onSegmentStopped`.
 *
 * Не использовать из продакшен-кода. Имя содержит `ForTest`, чтобы
 * grep по prod-коду не нашёл случайных вызовов.
 */
export function pushSegmentForTest(segment: Blob): void {
  segments.push(segment);
  if (segments.length > MAX_SEGMENTS) {
    segments.shift();
  }
}

/**
 * Полная очистка буферов: завершённые сегменты, in-flight чанки и
 * запланированная ротация. Вызывается из `onUserStoppedSharing` и
 * между тестами.
 */
export function resetBuffer(): void {
  segments = [];
  currentChunks = [];
  if (rotationTimerId !== null) {
    clearTimeout(rotationTimerId);
    rotationTimerId = null;
  }
}

// ─── Проверка кодека (D-20 strict-mode — no fallback chain) ─────────────

// WR-02 fix: assertCodecSupported is a PURE predicate that throws. The
// previous implementation broadcast a RECORDING_ERROR before throwing,
// which violated single responsibility (a function named `assert*`
// shouldn't have side effects) AND double-emitted with the
// startRecording catch block — popup received two RECORDING_ERROR
// messages for the same underlying problem. The single-source-of-truth
// for the broadcast is now `startRecording`'s catch block via
// classifyCaptureError.
export function assertCodecSupported(): void {
  const supported =
    typeof MediaRecorder !== 'undefined' &&
    typeof MediaRecorder.isTypeSupported === 'function' &&
    MediaRecorder.isTypeSupported(VIDEO_MIME);
  if (!supported) {
    const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '<unknown>';
    throw new Error(`vp9 unsupported. UA=${ua}`);
  }
}

// WR-01 fix: classify capture-pipeline failures into a stable,
// programmatically actionable error code. The previous implementation
// forwarded the raw DOMException message ("Permission denied by user",
// "Could not start video source", etc.) which changes between Chrome
// versions and locales. The popup / future telemetry needs a stable
// vocabulary — a string union the SW can switch on. The raw browser
// error stays in the logs at warn level for forensic value.
//
// Codes:
//   'user-cancelled'      — operator dismissed the getDisplayMedia picker
//                           (DOMException name 'NotAllowedError' with no
//                           system-permission denial context).
//   'permission-denied'   — system-level screen-recording permission denied
//                           by the OS (macOS Screen Recording privacy
//                           toggle, etc.). NotAllowedError + DOMException
//                           message hints OR SecurityError.
//   'codec-unsupported'   — assertCodecSupported() threw; vp9 not in
//                           MediaRecorder.isTypeSupported.
//   'no-source-selected'  — NotFoundError: picker yielded no source
//                           (rare; theoretically impossible if the picker
//                           closes via Cancel — that is NotAllowedError).
//   'capture-failed'      — AbortError / generic stream-acquisition failure.
//   'unknown'             — anything else; we still log the raw error.
export type CaptureErrorCode =
  | 'user-cancelled'
  | 'permission-denied'
  | 'codec-unsupported'
  | 'no-source-selected'
  | 'capture-failed'
  | 'wrong-display-surface'   // Plan 01-09 D-15-display-surface
  | 'unknown';

export function classifyCaptureError(error: unknown): CaptureErrorCode {
  if (error instanceof Error) {
    // Plan 01-09 D-15-display-surface: post-grant validation throws
    // `Error('wrong-display-surface: got "<surface>"…')` when the
    // operator overrode the `displaySurface: 'monitor'` HINT (the spec
    // does not treat it as a hard constraint — see Task 2 block comment
    // near the getDisplayMedia call site for the constraint-hint-vs-
    // enforcement rationale). Prefix-matching is safe: we control the
    // message string entirely; it never wraps a DOMException.
    if (error.message.startsWith('wrong-display-surface')) {
      return 'wrong-display-surface';
    }
    // Our own assertion is the cleanest signal — it never wraps a
    // DOMException, so prefix-matching is safe and stable.
    if (error.message.startsWith('vp9 unsupported')) {
      return 'codec-unsupported';
    }
    // DOMException has a stable `name` field per the WebIDL standard:
    // https://webidl.spec.whatwg.org/#idl-DOMException
    // The name does NOT vary between Chrome versions or locales, unlike
    // the human-readable message — exactly what we want for routing.
    const name = (error as { name?: string }).name;
    if (name === 'NotAllowedError') {
      // Distinguish system-permission denial from user-cancel by sniffing
      // the message for the "system" keyword Chrome uses on macOS denial
      // ("Permission denied by system"). On Linux/Windows the system case
      // is typically wrapped as a SecurityError instead — handled below.
      // The message text is locale-stable for English Chrome; for other
      // locales we fall back to 'user-cancelled' which is the dominant
      // NotAllowedError path in practice.
      if (/system/i.test(error.message)) {
        return 'permission-denied';
      }
      return 'user-cancelled';
    }
    if (name === 'SecurityError') {
      return 'permission-denied';
    }
    if (name === 'NotFoundError') {
      return 'no-source-selected';
    }
    if (name === 'AbortError') {
      return 'capture-failed';
    }
  }
  return 'unknown';
}

// ─── Захват экрана (getDisplayMedia inside offscreen — D-01) ────────────

async function startRecording(): Promise<void> {
  if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
    logger.warn('Recording already active; ignoring duplicate START_RECORDING');
    return;
  }
  try {
    assertCodecSupported();    // throws if vp9 missing — no fallback
    // Plan 01-09 D-15-display-surface (UAT Test 3 "Share this tab"
    // footgun retirement, disambiguated from the historical
    // "D-15: operator switching tabs" decision per B-02 fix):
    //   • `displaySurface: 'monitor'` hints Chrome's picker to default
    //     to entire-screen selection instead of the tab list.
    //   • `cursor: 'always'` opportunistically lifts the Phase 5
    //     cursor-visibility refinement (we want the operator's pointer
    //     captured for debugging context).
    // Both constraints are HINTS per the W3C Screen Capture spec — the
    // operator can still override via the picker UI. The post-grant
    // validation block immediately below is the actual enforcement.
    // Type note: lib.dom.d.ts MediaTrackConstraints omits `cursor`
    // (the Screen Capture spec defines it but TypeScript's bundled
    // types lag — https://developer.mozilla.org/docs/Web/API/MediaTrackConstraints/cursor).
    // Use a typed widening through `DisplayMediaStreamOptions &
    // {video: Record<...>}` instead of `as any` to stay explicit
    // about the precise field we're adding.
    //
    // Plan 01-14: `monitorTypeSurfaces: 'include'` is a TOP-LEVEL member
    // of DisplayMediaStreamOptions (W3C Screen Capture spec §6.1 — NOT
    // nested in `video:`; that would route it to MediaTrackConstraints).
    // Chrome ≥ 119 (Chrome screen-sharing-controls release notes:
    // https://developer.chrome.com/docs/web-platform/screen-sharing-controls)
    // honors the constraint by narrowing the picker dialog to ONLY
    // monitor surfaces — the Window and Chrome-Tab panes are elided.
    // The post-grant validation block immediately below remains the
    // actual enforcement (constraints are HINT-class per spec — the
    // operator can still override via the picker UI on older Chrome
    // versions, and managed-policy / DevTools can override anywhere).
    // Belt (picker-UI narrowing) + suspenders (post-grant tear-down)
    // per Plan 01-10 RESEARCH §5 + §Pitfall-5 recommendation.
    const stream = await navigator.mediaDevices.getDisplayMedia({
      video: { displaySurface: 'monitor', cursor: 'always' },
      monitorTypeSurfaces: 'include',    // Plan 01-14 — Chrome ≥ 119 picker narrowing per W3C spec §6.1
      audio: false,    // SPEC §9 — Phase 2 / CAP-01 territory
    } as DisplayMediaStreamOptions & {
      video: { displaySurface: 'monitor'; cursor: 'always' };
      monitorTypeSurfaces: 'include';
    });
    mediaStream = stream;
    // Plan 01-11 Task 2: wire the live MediaStream into the test hook
    // surface so the harness can read displaySurface (assertion 3) and
    // dispatch 'ended' on the track (assertion 6 — Bug B BLOCKER).
    // Gated identically to the top-of-module import; tree-shaken in
    // production by the `__MOKOSH_UAT__ === false` dead branch.
    if (__MOKOSH_UAT__) {
      testHooks?.setCurrentStream(stream);
      testHooks?.setSegmentCountGetter(() => segments.length);
    }
    // Post-grant validation — the constraint-hint-vs-enforcement gap.
    // getDisplayMedia's `displaySurface` is a HINT, not a hard
    // constraint: the operator may pick a tab/window from the picker
    // UI regardless of what we requested. We enforce monitor-only
    // here by inspecting the actual track settings. On mismatch we
    // tear down the stream and throw a routable error — the catch
    // block below classifies it via classifyCaptureError, broadcasts
    // RECORDING_ERROR{code: 'wrong-display-surface'}, and re-throws.
    const videoTrack = stream.getVideoTracks()[0];
    if (videoTrack !== undefined) {
      const observed = videoTrack.getSettings().displaySurface;
      if (observed !== 'monitor') {
        stream.getTracks().forEach((t) => {
          try { t.stop(); } catch (_e) { /* defensive — stop may throw on
            already-stopped tracks; ignore. */ }
        });
        mediaStream = null;
        throw new Error(
          `wrong-display-surface: got "${observed}", expected "monitor"`,
        );
      }
    }
    // Track end detection — RESEARCH.md Example F. Attach to ALL tracks
    // (Pitfall 6) так, чтобы edge-case-аудио-трек не оставил нас в
    // несинхронизованном состоянии. Регистрация — один раз на поток;
    // при ротации MediaRecorder поток тот же, перерегистрировать не нужно.
    stream.getTracks().forEach((track) => {
      track.addEventListener('ended', onUserStoppedSharing, { once: true });
    });
    // Чистим возможные остатки прежней сессии (на случай повторного
    // START_RECORDING после ручного STOP_RECORDING без перезагрузки
    // offscreen-документа).
    resetBuffer();
    startNewSegment();
    logger.log(
      'Recording started (D-13 restart-segments), mime:', VIDEO_MIME,
      'segment_ms:', SEGMENT_DURATION_MS,
      'max_segments:', MAX_SEGMENTS,
    );
  } catch (error) {
    // WR-01: emit a stable error code, not the raw DOMException message.
    // Raw message stays in logs at warn level for forensic value.
    const code = classifyCaptureError(error);
    const msg = error instanceof Error ? error.message : String(error);
    logger.warn('startRecording failed (raw):', msg);
    logger.error('startRecording failed (code):', code);
    chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: code });
    throw error;
  }
}

/**
 * Запуск нового сегмента: создаём свежий MediaRecorder на том же
 * MediaStream, навешиваем обработчики, стартуем без timeslice (один
 * dataavailable на остановку → один blob на сегмент → один keyframe в
 * заголовке за счёт fresh-encoder-инициализации).
 *
 * Sweep #3 hardening: the MediaRecorder constructor and .start() may
 * throw (codec mid-session unavailability, GPU/driver hot-swap, etc.).
 * Without a guard the rotation chain would silently die — onSegmentStopped
 * is never called again, no new RECORDING_ERROR is emitted, the popup
 * shows green while nothing is recording. Catch + classify + emit +
 * tear down the session so the operator gets actionable feedback.
 */
function startNewSegment(): void {
  if (mediaStream === null) {
    logger.warn('startNewSegment called without an active mediaStream — abort');
    return;
  }
  currentChunks = [];
  try {
    videoRecorder = new MediaRecorder(mediaStream, {
      mimeType: VIDEO_MIME,
      videoBitsPerSecond: VIDEO_BITRATE,
    });
    videoRecorder.ondataavailable = onDataAvailable;
    videoRecorder.onstop = onSegmentStopped;
    videoRecorder.onerror = (event) => logger.error('MediaRecorder error:', event);
    // Без timeslice: одно событие dataavailable придёт по .stop() — это
    // ровно один blob, содержащий целиком сегмент (EBML-заголовок +
    // кластеры). Так каждый сегмент гарантированно декодируется
    // независимо. Если когда-то потребуется живая стат-телеметрия,
    // можно дать timeslice назад без изменения семантики ротации.
    videoRecorder.start();
    scheduleRotation();
  } catch (err) {
    // Sweep #3 fix: MediaRecorder construction / start failed mid-session.
    // Most common cause is the codec becoming unavailable (GPU hot-swap,
    // driver change). Classify, notify, and tear down so the operator
    // sees an actionable error instead of silent recording cessation.
    const code = classifyCaptureError(err);
    const msg = err instanceof Error ? err.message : String(err);
    logger.warn('startNewSegment failed (raw):', msg);
    logger.error('startNewSegment failed (code):', code);
    chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: code });
    // Tear down — same shape as onUserStoppedSharing's cleanup so the
    // SW-side state machine doesn't get a half-recorded session.
    const streamToStop = mediaStream;
    mediaStream = null;
    videoRecorder = null;
    if (rotationTimerId !== null) {
      clearTimeout(rotationTimerId);
      rotationTimerId = null;
    }
    if (streamToStop !== null) {
      streamToStop.getTracks().forEach((t) => {
        try {
          t.stop();
        } catch (terr) {
          logger.warn('track.stop() during startNewSegment cleanup failed:', terr);
        }
      });
    }
  }
}

/**
 * Планируем следующую ротацию через SEGMENT_DURATION_MS. Хэндл
 * сохраняем в `rotationTimerId`, чтобы `resetBuffer` / остановка
 * могли её отменить — иначе на остановленном MediaRecorder будет
 * вызван .stop(), что бросит исключение.
 */
function scheduleRotation(): void {
  if (rotationTimerId !== null) {
    clearTimeout(rotationTimerId);
  }
  rotationTimerId = setTimeout(rotateSegment, SEGMENT_DURATION_MS);
}

/**
 * Вызывает stop() на текущем рекордере. Финальный ondataavailable
 * и onstop сработают синхронно сразу после; именно в onSegmentStopped
 * сегмент будет упакован и запушен в массив сегментов.
 */
function rotateSegment(): void {
  if (videoRecorder === null) {
    return;
  }
  if (videoRecorder.state !== 'recording') {
    // Кто-то остановил рекордер вручную раньше таймера — выходим
    // молча; стартовать новый сегмент тут не нужно (он будет запущен
    // следующим START_RECORDING, если он придёт).
    return;
  }
  try {
    videoRecorder.stop();    // onstop → onSegmentStopped → новый сегмент
  } catch (err) {
    logger.error('rotateSegment: stop() failed:', err);
  }
}

/**
 * Финализация одного сегмента: склеиваем накопленные dataavailable-чанки
 * в один Blob, кладём в `segments`, обрезаем до MAX_SEGMENTS, стартуем
 * следующий сегмент на том же mediaStream.
 *
 * Edge case: если по какой-то причине currentChunks пустой (например,
 * MediaRecorder.stop() сработал прежде, чем энкодер успел эмитнуть хоть
 * один пакет — теоретически возможно сразу после start() без timeslice
 * на пустом потоке), сегмент не пушим — не корраптим буфер пустым blob-ом.
 */
function onSegmentStopped(): void {
  if (currentChunks.length > 0) {
    const segmentBlob = new Blob(currentChunks, { type: 'video/webm' });
    segments.push(segmentBlob);
    if (segments.length > MAX_SEGMENTS) {
      segments.shift();
    }
    logger.log(
      'Segment finalized, size:', segmentBlob.size,
      'kept:', segments.length, '/', MAX_SEGMENTS,
    );
  } else {
    logger.warn('Segment finalized with zero chunks — skipping push');
  }
  currentChunks = [];
  // Стартуем следующий сегмент только если поток ещё активен. Если
  // пользователь нажал «прекратить шеринг» между stop() и сюда —
  // onUserStoppedSharing уже сбросил mediaStream в null.
  if (mediaStream !== null) {
    startNewSegment();
  }
}

function onDataAvailable(event: BlobEvent): void {
  if (!event.data || event.data.size === 0) {
    return;
  }
  // Накапливаем в per-segment буфер; финализация — в onSegmentStopped.
  currentChunks.push(event.data);
}

// Sweep #4 fix: onUserStoppedSharing is registered with `{ once: true }`
// on EACH track of the captured stream. With multiple tracks (video +
// audio — audio currently always disabled per D-13 but the registration
// walks `getTracks()` defensively), the handler could fire twice if
// both tracks emit `ended` in close succession. `resetBuffer()` and the
// stream-cleanup steps are idempotent, but the `chrome.runtime.sendMessage`
// would double-emit RECORDING_ERROR — same double-emit pattern WR-02
// fixed for the codec/getDisplayMedia path. Guard with a flag that
// gates the broadcast + cleanup.
let teardownInProgress = false;

function onUserStoppedSharing(): void {
  if (teardownInProgress) {
    logger.log('onUserStoppedSharing already ran — second track ended, ignoring');
    return;
  }
  teardownInProgress = true;
  logger.log('Operator stopped sharing — clearing buffer');
  resetBuffer();
  if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
    try {
      videoRecorder.stop();
    } catch (err) {
      logger.warn('stop() on user-stopped-sharing failed:', err);
    }
  }
  if (mediaStream !== null) {
    mediaStream.getTracks().forEach((t) => t.stop());
    mediaStream = null;
  }
  videoRecorder = null;
  // Plan 01-11 Task 2: clear the test-hook stream reference so a
  // subsequent harness `getCurrentStream()` returns null (the "not
  // currently recording" signal documented in src/test-hooks/types.ts).
  // Tree-shaken in production by the `__MOKOSH_UAT__ === false` dead branch.
  if (__MOKOSH_UAT__) {
    testHooks?.setCurrentStream(null);
  }
  chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: 'user-stopped-sharing' });
  // Reset the guard so a future startRecording → onUserStoppedSharing
  // cycle works correctly. Place AFTER the broadcast so a same-tick
  // second invocation is still gated.
  // Use a microtask deferral (queueMicrotask) so the reset happens after
  // every synchronous re-entrant invocation in the same dispatcher tick.
  queueMicrotask(() => {
    teardownInProgress = false;
  });
}

function stopRecording(): void {
  // Sweep #1 fix: race between rotateSegment / onSegmentStopped and a
  // STOP_RECORDING that does NOT null the mediaStream. The previous
  // implementation cleared the rotation timer and called .stop(), which
  // triggers onSegmentStopped async; onSegmentStopped then unconditionally
  // calls startNewSegment() whenever `mediaStream !== null`. Result: after
  // a manual STOP_RECORDING, a new segment would spawn anyway and the
  // recorder kept rolling silently. The fix mirrors onUserStoppedSharing:
  // null the mediaStream FIRST so onSegmentStopped's gate sees the stopped
  // state, then stop the recorder and release the tracks. This also fixes
  // the secondary issue of leaked MediaStreamTracks (the picker indicator
  // would have stayed up until the operator manually clicked "Stop
  // sharing" in Chrome's UI).
  if (rotationTimerId !== null) {
    clearTimeout(rotationTimerId);
    rotationTimerId = null;
  }
  // Capture stream before nulling so we can release its tracks.
  const streamToStop = mediaStream;
  mediaStream = null;
  if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
    try {
      videoRecorder.stop();
    } catch (err) {
      logger.warn('stop() during stopRecording failed:', err);
    }
    logger.log('Recording stopped manually');
  }
  if (streamToStop !== null) {
    streamToStop.getTracks().forEach((t) => t.stop());
  }
  // Note: we do NOT call resetBuffer() here — the operator may want to
  // STOP and then SAVE the buffered footage. resetBuffer happens on
  // onUserStoppedSharing (operator dismissed Chrome's sharing indicator)
  // and at the start of the next startRecording().
}

// ─── Bootstrap: handshake + port lifecycle (D-17, RESEARCH.md Patterns 4 & 5) ──
// T-1-04 sender-id check: defense-in-depth on the offscreen side. The SW-side
// `onConnect` listener (Plan 05) MUST also validate
// `port.sender?.id === chrome.runtime.id` to reject port-hijack attempts
// from other extensions. See threat register T-1-04 in 01-04-PLAN.md.

function isFromOwnExtension(sender: chrome.runtime.MessageSender | undefined): boolean {
  return sender?.id === chrome.runtime.id;
}

// Stable handle for the ping interval. The legacy 290 s pre-emptive
// reconnect setTimeout was retired in the Option C refactor — its race
// window between the synchronous .disconnect() and the onDisconnect
// handler running was the proximate cause of the H1 "Attempting to use a
// disconnected port object" Uncaught Errors per the bisect notes in
// .planning/debug/empty-archive-port-race.md.
let pingIntervalId: ReturnType<typeof setInterval> | null = null;

// Option C health-probe state. The offscreen pings every PORT_PING_MS and
// expects a PONG echo from the SW. If MAX_MISSED_PONGS consecutive PINGs
// go unanswered, the port is considered dead and a clean reconnect is
// initiated via the same teardown path the onDisconnect handler uses.
// This replaces the time-based pre-emptive reconnect with a liveness-
// based one: the port lives until it stops responding, not until a wall
// clock says it should rotate.
let missedPongs = 0;

function teardownPortTimers(): void {
  if (pingIntervalId !== null) {
    clearInterval(pingIntervalId);
    pingIntervalId = null;
  }
}

// D-P2-01 Blob URL hygiene set: track minted URLs so we can warn (not
// error) on unexpected revoke ops. URL.revokeObjectURL on an unknown URL
// is a no-op per the WHATWG URL spec, so this is purely diagnostic. The
// Set lives at module scope because URL minting is stateless per-Blob
// and an instance-per-mint container would itself be the only consumer
// (the postMessage round-trip happens via requestId, not Set membership).
const mintedDownloadUrls = new Set<string>();

/**
 * D-P2-01 (P0-6 fix) CREATE_DOWNLOAD_URL handler: decode the SW-supplied
 * base64 archive bytes into a Blob, mint a `blob:chrome-extension://...`
 * URL via URL.createObjectURL, and post the URL string back to the SW
 * via the keepalivePort as a DOWNLOAD_URL message.
 *
 * Defense-in-depth: on any failure (empty base64, decode throw, mint
 * throw, port disconnected mid-flight) the function responds with
 * `{type:'DOWNLOAD_URL', requestId, url:''}` and lets the SW's outer
 * timeout fire. Mirrors the encodeAndSendBuffer best-effort pattern —
 * partial recovery > silent stall.
 *
 * @param requestId - Per-mint correlation id (echoed in the DOWNLOAD_URL
 *                    response). The SW's pendingDownloadUrlResolvers Map
 *                    is keyed by this id.
 * @param dataBase64 - Base64-encoded archive bytes (no `data:` prefix).
 * @param mimeType - MIME type to assign to the reconstructed Blob.
 *                   Defaults to 'application/zip' at the call site.
 */
async function handleCreateDownloadUrl(
  requestId: string,
  dataBase64: string,
  mimeType: string,
): Promise<void> {
  if (keepalivePort === null) {
    logger.warn('CREATE_DOWNLOAD_URL: port unavailable at handler entry — dropping');
    return;
  }
  let url = '';
  try {
    const blob = base64ToBlob(dataBase64, mimeType);
    if (blob.size === 0) {
      logger.warn('CREATE_DOWNLOAD_URL: empty blob from base64 decode — responding with empty url');
    } else {
      url = URL.createObjectURL(blob);
      mintedDownloadUrls.add(url);
      logger.log(`Minted Blob URL: ${url.substring(0, 30)}... (size: ${blob.size} bytes)`);
    }
  } catch (err) {
    logger.error('CREATE_DOWNLOAD_URL: base64ToBlob or createObjectURL threw:', err);
  }
  // Always respond, even on empty url — the SW's downloadArchive checks
  // `url === ''` and throws a typed `blob-url-mint-failed` error, which
  // routes through saveArchive's catch into the RECORDING_ERROR channel.
  try {
    keepalivePort.postMessage({ type: 'DOWNLOAD_URL', requestId, url });
  } catch (err) {
    logger.warn('DOWNLOAD_URL post failed (port may have disconnected):', err);
  }
}

function onPortMessage(message: unknown): void {
  // Defense-in-depth: explicit shape check before destructuring (T-1-04).
  if (typeof message !== 'object' || message === null) {
    return;
  }
  const type = (message as { type?: unknown }).type;
  if (type === 'PONG') {
    // Health-probe echo. Resetting missedPongs to 0 closes the probe loop
    // — the port is demonstrably alive.
    missedPongs = 0;
    return;
  }
  if (type === 'REQUEST_BUFFER') {
    // Option C: REQUEST_BUFFER carries a requestId the SW generated per
    // call. We echo it on the BUFFER response so the SW's per-request
    // listener can match by id (preventing cross-talk between concurrent
    // requests and between retries on port replacement).
    const requestId = (message as { requestId?: unknown }).requestId;
    if (typeof requestId !== 'string' || requestId.length === 0) {
      // Defense-in-depth: a REQUEST_BUFFER without a valid id is invalid
      // under the Option C protocol — drop with a warn. Legacy callers
      // are absent from the codebase; this is purely a guard against
      // future regressions or hostile peers.
      logger.warn('REQUEST_BUFFER without a valid requestId — dropping');
      return;
    }
    // Fire-and-forget: the chrome.runtime.Port.onMessage listener API
    // ignores the return value. We do the async base64 encoding inside
    // an IIFE so the listener stays synchronous-typed (void return).
    //
    // D-12 fix: each segment's Blob is encoded to base64 BEFORE postMessage
    // because chrome.runtime.Port JSON-serializes across extension contexts,
    // and JSON.stringify(blob) === "{}". See src/shared/binary.ts and
    // tests/offscreen/port-serialization.test.ts for the contract.
    void encodeAndSendBuffer(requestId);
    return;
  }
  if (type === 'CREATE_DOWNLOAD_URL') {
    // D-P2-01 (P0-6 fix): mint a Blob URL on behalf of the SW (which
    // lacks URL.createObjectURL per DEC-006). Each mint gets its own
    // requestId; concurrent mints are allowed because URL minting is
    // stateless per-Blob — unlike encodeAndSendBuffer, which gates
    // concurrent calls because they share the segment ring buffer state.
    const requestId = (message as { requestId?: unknown }).requestId;
    const dataBase64 = (message as { dataBase64?: unknown }).dataBase64;
    const mimeType = (message as { mimeType?: unknown }).mimeType;
    if (typeof requestId !== 'string' || requestId.length === 0) {
      logger.warn('CREATE_DOWNLOAD_URL without requestId — dropping');
      return;
    }
    if (typeof dataBase64 !== 'string') {
      logger.warn('CREATE_DOWNLOAD_URL with non-string dataBase64 — dropping');
      return;
    }
    const effectiveMime = typeof mimeType === 'string' ? mimeType : 'application/zip';
    // Fire-and-forget IIFE so the listener stays sync-typed; the helper
    // owns its own error reporting + DOWNLOAD_URL response post.
    void handleCreateDownloadUrl(requestId, dataBase64, effectiveMime);
    return;
  }
  if (type === 'REVOKE_DOWNLOAD_URL') {
    // D-P2-01: free the URL once chrome.downloads.onChanged reports the
    // download is complete or interrupted. URL.revokeObjectURL on an
    // unknown URL is a no-op per WHATWG spec, so the mintedDownloadUrls
    // Set is purely a diagnostic signal (unexpected revokes get a warn
    // but still execute).
    const url = (message as { url?: unknown }).url;
    if (typeof url !== 'string' || url.length === 0) {
      logger.warn('REVOKE_DOWNLOAD_URL without url — dropping');
      return;
    }
    if (!mintedDownloadUrls.has(url)) {
      logger.warn(`REVOKE_DOWNLOAD_URL for unminted url ${url.substring(0, 30)}... — proceeding anyway (WHATWG no-op)`);
    }
    try {
      URL.revokeObjectURL(url);
      mintedDownloadUrls.delete(url);
    } catch (err) {
      logger.warn('URL.revokeObjectURL threw:', err);
    }
    return;
  }
  // Any unknown port message type is silently dropped (T-1-04 defense-in-depth).
}

// Sweep #2 fix: in-flight guard against re-entrant encodeAndSendBuffer.
// The SW only issues one REQUEST_BUFFER per saveArchive in production, but
// nothing in the API design forbids back-to-back REQUEST_BUFFER messages.
// Without this guard, two concurrent encode passes would:
//   (a) interleave `++segmentSeq` increments — each request's segments
//       end up with non-contiguous timestamps that look like gaps from
//       the SW sort perspective (benign but noisy)
//   (b) both call getSegments() against the same buffer snapshot, so
//       the SW would receive two BUFFERs with overlapping content if
//       it accidentally combined them
//   (c) inflate base64-encode CPU cost unnecessarily during the
//       encode latency window (~150 ms for 3 segments)
// The guard drops the second concurrent call with a warn log; the SW
// timeout fires cleanly and the next saveArchive retries on the fresh
// post-completion state.
let encodeInFlight = false;

async function encodeAndSendBuffer(requestId: string): Promise<void> {
  if (encodeInFlight) {
    logger.warn('encodeAndSendBuffer already running — dropping concurrent call');
    return;
  }
  if (keepalivePort === null) {
    logger.warn('encodeAndSendBuffer called without an active port — drop');
    return;
  }
  encodeInFlight = true;
  try {
    await doEncodeAndSendBuffer(requestId);
  } finally {
    encodeInFlight = false;
  }
}

async function doEncodeAndSendBuffer(requestId: string): Promise<void> {
  // WR-09 fix: an in-flight segment lacks the Matroska finalization that
  // MediaRecorder.stop() performs (SegmentSize, Cues) — splicing it onto
  // a finalized tail re-introduces the "File ended prematurely" symptom
  // documented in the debug session webm-playback-freeze. We still want
  // SOME data if SAVE_ARCHIVE fires within the first 10 s window (before
  // any segment rotation has completed); in that single case we accept
  // the trade-off and ship the in-flight Blob alone. Once we have at
  // least one finalized segment, we drop the in-flight tail unconditionally.
  const finalized = getSegments();
  const inFlight =
    finalized.length === 0 && currentChunks.length > 0
      ? new Blob(currentChunks, { type: 'video/webm' })
      : null;
  const allSegments: Blob[] =
    inFlight !== null ? [inFlight] : finalized;

  // WR-03 fix: monotonically-increasing per-process counter, NOT
  // `Date.now() + idx`. Date.now() at millisecond resolution collides
  // across two REQUEST_BUFFER calls within the same millisecond (e.g.,
  // diagnostic prefetch + real save). The merge code on the SW side
  // sorts by `timestamp` ascending, so a strictly monotonic counter
  // guarantees a deterministic order independent of wall-clock skew.
  const transferred: TransferredVideoSegment[] = [];
  for (let idx = 0; idx < allSegments.length; idx++) {
    const segment = allSegments[idx];
    try {
      const data = await blobToBase64(segment);
      transferred.push({
        data,
        type: segment.type || 'video/webm',
        timestamp: ++segmentSeq,
      });
    } catch (err) {
      // Per-segment defensive encode: a single Blob failing to encode
      // (e.g. unexpected ArrayBuffer detach) is logged and skipped —
      // partial video > no video at all.
      logger.error(
        'blobToBase64 failed; skipping segment',
        'index:', idx,
        'size:', segment.size,
        'error:', err,
      );
    }
  }
  // After the await: post the BUFFER on the CURRENT keepalivePort. Under
  // the Option C protocol the BUFFER carries the requestId the SW issued,
  // so even if the port was replaced mid-encode (and the SW's matching
  // listener migrated to the new port via the SW-side retry — see
  // src/background/index.ts:getVideoBufferFromOffscreen), the BUFFER
  // routes correctly by id. This retires the H2 silent-drop class that
  // the bisect (.planning/debug/empty-archive-port-race.md) traced to
  // the CR-01 "refuse-to-post on swapped port" pattern.
  //
  // Edge case: if the encode took so long that keepalivePort is now null
  // (e.g. mid-reconnect with no fresh port yet) we drop with a warn.
  // The SW's outer timeout will eventually fire and surface the error to
  // the operator (no more silent video-less archive).
  if (keepalivePort === null) {
    logger.warn(
      'port unavailable at post time; dropping BUFFER (SW outer timeout will surface)',
    );
    return;
  }
  try {
    keepalivePort.postMessage({
      type: 'BUFFER',
      requestId,
      segments: transferred,
    });
  } catch (err) {
    // The current port may have been disconnected between the null-check
    // above and postMessage. Treat as a dropped response — the SW retries
    // on the fresh port.
    logger.warn('BUFFER post failed (port disconnected mid-post):', err);
  }
}

// Triggers a clean reconnect: tear down timers, disconnect the current
// port (if any), null the reference, and re-enter connectPort(). The
// disconnect call fires the onDisconnect handler on the local side too
// (Chrome's documented behaviour for explicit .disconnect()), and that
// handler also calls connectPort() — but the missedPongs reset + the
// teardownPortTimers idempotency keep the second pass a no-op.
function reconnectPort(reason: string): void {
  logger.warn('Initiating port reconnect:', reason);
  teardownPortTimers();
  const stale = keepalivePort;
  keepalivePort = null;
  missedPongs = 0;
  if (stale !== null) {
    try {
      stale.disconnect();
    } catch (err) {
      // Disconnecting an already-disconnected port throws on some Chrome
      // versions; silently ignored — the local-side reference has already
      // been cleared so no further use is possible.
      logger.warn('stale.disconnect() during reconnect failed:', err);
    }
  }
  connectPort();
}

function connectPort(): void {
  teardownPortTimers();
  try {
    keepalivePort = chrome.runtime.connect({ name: PORT_NAME });
  } catch (err) {
    logger.error('port connect failed:', err);
    keepalivePort = null;
    return;
  }
  missedPongs = 0;
  keepalivePort.onMessage.addListener(onPortMessage);
  keepalivePort.onDisconnect.addListener(() => {
    logger.warn('port disconnected — reconnecting');
    teardownPortTimers();
    keepalivePort = null;
    missedPongs = 0;
    // Synchronous reconnect — tests/offscreen/port.test.ts pins this.
    connectPort();
  });
  pingIntervalId = setInterval(() => {
    // H1 fix: wrap postMessage in try/catch. The port object can become
    // disconnected between the interval-callback being queued and it
    // running (race window between local .disconnect() and onDisconnect
    // firing). Chrome's documented runtime semantics make postMessage
    // throw synchronously with "Attempting to use a disconnected port
    // object". Without the catch, that became an Uncaught Error in the
    // offscreen console (3x at 290 s in UAT Test 3).
    //
    // Health probe: each ping costs us one missed-PONG slot. The SW
    // echoes PONG on PING (src/background/index.ts), and onPortMessage
    // resets missedPongs to 0 on receipt. If MAX_MISSED_PONGS pings go
    // without a PONG, the SW (or the port) is unresponsive and we
    // reconnect cleanly.
    if (keepalivePort === null) {
      return;
    }
    missedPongs++;
    if (missedPongs > MAX_MISSED_PONGS) {
      reconnectPort(`missed ${missedPongs} PONGs — port unresponsive`);
      return;
    }
    try {
      keepalivePort.postMessage({ type: 'PING' });
    } catch (err) {
      // The port went disconnected synchronously — initiate the same
      // clean reconnect path. The throw caught here is exactly the H1
      // "Attempting to use a disconnected port object" surface.
      reconnectPort(`PING postMessage threw: ${String(err)}`);
    }
  }, PORT_PING_MS);
}

// Бутстрап (handshake + port lifecycle).
// Guarded so the pure segment-rotation + codec-check tests can import the
// module without providing a full chrome stub. Production always has
// chrome.runtime fully populated, so the guard is a no-op there. Order
// matters per Pattern 4: onMessage listener registration MUST happen BEFORE
// OFFSCREEN_READY is sent, so the SW can safely send START_RECORDING the
// moment it sees the ready signal.
function bootstrap(): void {
  if (typeof chrome === 'undefined' || !chrome.runtime) {
    return;
  }
  if (typeof chrome.runtime.onMessage?.addListener === 'function') {
    chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => {
      if (!isFromOwnExtension(sender)) {
        return false;
      }
      switch (message.type) {
        case 'START_RECORDING':
          startRecording()
            .then(() => sendResponse({ ok: true }))
            .catch((err) => sendResponse({ ok: false, error: String(err) }));
          return true;
        case 'STOP_RECORDING':
          stopRecording();
          sendResponse({ ok: true });
          return false;
        default:
          return false;
      }
    });
  }
  if (typeof chrome.runtime.connect === 'function') {
    connectPort();
  }
  if (typeof chrome.runtime.sendMessage === 'function') {
    chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' });
  }
}

bootstrap();
