import { Logger } from '../shared/logger';
import { base64ToBlob, blobToBase64 } from '../shared/binary';
import type {
  Message,
  TransferredVideoSegment,
  VideoSegment,
  SessionMetadata,
  VideoBufferResponse
} from '../shared/types';
import { remuxSegments } from './webm-remux';
// Plan 02-03 D-P2-02 — tab-url tracker for meta.urls (multi-tab context).
// initTabUrlTracker is called from initialize(); snapshotOpenTabs +
// getTabUrlsSeen are called from createArchive() at SAVE time. The
// always-on charter (Plan 01-09 Amendment 3) is preserved — no
// clearTabUrlsSeen call in the save flow.
import { initTabUrlTracker, snapshotOpenTabs, getTabUrlsSeen } from './tab-url-tracker';
import JSZip from 'jszip';

// ─── Plan 01-13: NO SW-side test hook gate (Approach B) ──────────────
// Plan 01-11 originally planned a gated `await import('../test-hooks/sw-hooks')`
// here to instrument chrome.* handler registrations from the SW side.
// EMPIRICAL FALSIFICATION (01-11 spike, see 01-11-SUMMARY.md): MV3
// service workers BLOCK dynamic import (Chromium es_modules.md:
// "Dynamic import is currently blocked in Service Workers, but it
// will change in the future."; w3c/webextensions#212 still open as of
// May 2026). The `await import(...)` never resolved → the SW silently
// died at top-of-module init → production chrome.* listeners never
// registered → the entire service worker was non-functional.
//
// Plan 01-13 (Approach B) DROPS the SW-side hook entirely:
//   - The OFFSCREEN-side test hook (src/test-hooks/offscreen-hooks.ts)
//     still works because offscreen IS a DOM document where dynamic
//     import is supported. See src/offscreen/recorder.ts top-of-module
//     for the surviving `if (__MOKOSH_UAT__) { await import(...) }`.
//   - SW-side state (badge text, popup, isRecording proxy) is queried
//     by the extension-internal harness page via chrome.action.*
//     directly (the page has full chrome.* privilege, unlike the
//     restricted CDP `sw.evaluate` surface that 01-11 tried first).
//   - Architectural rationale + full falsification table:
//     .planning/phases/01-stabilize-video-pipeline/01-11-SUMMARY.md
//
// SECURITY INVARIANT (T-1-11-01, retained): production bundle MUST
// contain NO test-hook surface strings. The Tier-1 grep gate
// `tests/background/no-test-hooks-in-prod-bundle.test.ts` enforces
// post-build. In Approach B this invariant is trivially satisfied for
// the SW chunk (no hook imports here at all); the gate's enduring
// value is catching regressions in the offscreen chunk's
// `__MOKOSH_UAT__` gate.

// Default MIME applied when a wire chunk somehow lacks a type
// field (defense-in-depth: in normal operation the offscreen recorder
// always populates it from chunk.data.type). Matches D-20 strict codec.
const VIDEO_MIME_FALLBACK = 'video/webm;codecs=vp9';

const logger = new Logger('Main');

// Option C: a typed error so saveArchive can distinguish the empty-video
// failure (operator-facing — shows a clear RECORDING_ERROR popup) from
// generic createArchive failures (zip/manifest/etc.). The 'empty-video-
// buffer' code joins the CaptureErrorCode union surfaced by the
// offscreen recorder (see src/offscreen/recorder.ts: classifyCaptureError)
// — same operator-facing vocabulary, different production point.
export class EmptyVideoBufferError extends Error {
  readonly code = 'empty-video-buffer' as const;
  constructor(detail: string) {
    super(`empty-video-buffer: ${detail}`);
    this.name = 'EmptyVideoBufferError';
  }
}

// Состояние
// Видеобуфер живёт в offscreen-документе (D-16). SW не хранит чанки локально:
// при экспорте он спрашивает буфер у offscreen через long-lived port (D-17).
let isRecording = false;
let offscreenCreated = false;
let lastScreenshotTime = 0;
let cachedScreenshot: Blob | null = null;

// ─── Plan 01-09 badge palette + notification constants ───────────────
// Project naming standard: SCREAMING_SNAKE for true constants. These
// drive the operator-facing badge state machine + notification flow.
//
// Plan 01-12 Wave 4: BADGE_REC_COLOR updated from #00C853 (material
// green) to #b2543d (= --mks-madder-600 per D-04 loom palette per
// RESEARCH §10 Open Question A7). OFF + ERROR colors retained as
// engineering choices (no loom-palette token for material-red /
// material-amber-700 equivalents); document choice inline.
//
// Plan 01-12 Wave 4: BADGE_*_TITLE constants kept as FALLBACKS for the
// chrome.i18n.getMessage reads at the setBadgeState call sites. Unit
// tests that don't stub chrome.i18n degrade to these literals rather
// than empty strings.
const BADGE_REC_COLOR = '#b2543d';     // --mks-madder-600 per D-04 loom palette (Plan 01-12)
const BADGE_OFF_COLOR = '#D32F2F';     // material red (no loom-palette token for OFF)
const BADGE_ERROR_COLOR = '#F9A825';   // material amber-700 (no loom-palette token for ERR)
const BADGE_REC_TEXT = 'REC';
const BADGE_OFF_TEXT = '';
const BADGE_ERROR_TEXT = 'ERR';
const BADGE_REC_TITLE_FALLBACK = 'Recording — last 30 s buffered. Click to save.';
const BADGE_OFF_TITLE_FALLBACK = 'Not recording. Click to start.';
const BADGE_ERROR_TITLE_FALLBACK = 'Recording error. Click to try again.';
const NOTIFICATION_ICON_PATH = 'icons/icon128.png';
const NOTIFICATION_STARTUP_PREFIX = 'mokosh-startup-';
const NOTIFICATION_RECOVERY_PREFIX = 'mokosh-recovery-';
const POPUP_HTML_PATH = 'src/popup/index.html';

// ─── Plan 01-10 onboarding constants (D-17-onboarding) ───────────────
// Project naming standard: SCREAMING_SNAKE for true constants. These
// drive the first-install welcome-tab flow (chrome.runtime.onInstalled
// → openWelcomeIfFirstInstall; flag persisted via chrome.storage.local).
// The 'onboarding-completed' key is the storage-schema identifier
// preserved across SW respawns (and surveyed by the unit test in
// tests/background/onboarding.test.ts Test A).
const ONBOARDING_FLAG = 'onboarding-completed';
const ONBOARDING_INSTALLED_AT = 'installed-at';
const WELCOME_PATH = 'src/welcome/welcome.html';

// Plan 01-12 Wave 4: operator-facing copy fallbacks for the notification
// title (extName) + the two notification messages. Same `|| fallback`
// pattern as the popup — unit-test contexts without chrome.i18n stub
// degrade to these literals.
const NOTIF_EXTNAME_FALLBACK = 'Mokosh';
// Plan 01-09 amendment 2026-05-20: `notifStartup` key was split into
// `notifStartupCta` (used here by the onStartup handler — CTA-with-gesture
// pre-recording invite) and `notifRecordingStarted` (reserved for future
// post-manual-start confirmation flows). The original text falsely implied
// recording had auto-started; the new text invites the operator to start
// a recording. See .planning/debug/resolved/01-09-startup-notification-misleading-text.md.
const NOTIF_STARTUP_CTA_FALLBACK = 'Mokosh ready. Click to start a recording.';
const NOTIF_RECOVERY_FALLBACK = 'Recording stopped. Click here to start a new session.';

/**
 * Safe wrapper around chrome.i18n.getMessage with explicit fallback.
 * Returns the fallback when chrome.i18n is undefined (unit-test contexts
 * without a stub) OR when the key is missing in the resolved locale.
 *
 * chrome.i18n.getMessage is SYNCHRONOUS per Chrome docs — no Promise.
 * Returns '' for missing keys, hence the explicit length check.
 */
function i18nMessage(key: string, fallback: string): string {
  const text = chrome?.i18n?.getMessage?.(key) ?? '';
  return text.length > 0 ? text : fallback;
}

// ─── Plan 01-09 badge state machine + mode helpers ───────────────────
// 3-state machine: REC (during recording), OFF (idle), ERROR (after
// RECORDING_ERROR). Each setBadgeState call is best-effort: chrome.action
// methods may be undefined in unit-test contexts that did not stub the
// whole surface — wrap each call in try/catch (defense in depth).
type BadgeState = 'REC' | 'OFF' | 'ERROR';

function setBadgeState(state: BadgeState): void {
  let text: string;
  let color: string;
  let title: string;
  if (state === 'REC') {
    text = BADGE_REC_TEXT;
    color = BADGE_REC_COLOR;
    title = i18nMessage('tooltipRecPrefix', BADGE_REC_TITLE_FALLBACK);
  } else if (state === 'OFF') {
    text = BADGE_OFF_TEXT;
    color = BADGE_OFF_COLOR;
    title = i18nMessage('tooltipOff', BADGE_OFF_TITLE_FALLBACK);
  } else {
    text = BADGE_ERROR_TEXT;
    color = BADGE_ERROR_COLOR;
    title = i18nMessage('tooltipErr', BADGE_ERROR_TITLE_FALLBACK);
  }
  try { chrome.action.setBadgeText({ text }); } catch (e) { logger.warn('setBadgeText failed:', e); }
  try { chrome.action.setBadgeBackgroundColor({ color }); } catch (e) { logger.warn('setBadgeBackgroundColor failed:', e); }
  try { chrome.action.setTitle({ title }); } catch (e) { logger.warn('setTitle failed:', e); }
}

// In idle mode the popup is empty ('' string) — this is what makes
// chrome.action.onClicked fire on toolbar clicks (per the MV3 docs:
// the click event only fires when no default_popup is set). In REC/
// ERROR modes the popup html is set back so toolbar clicks open the
// SAVE-only popup.
function setIdleMode(): void {
  try { chrome.action.setPopup({ popup: '' }); } catch (e) { logger.warn('setPopup OFF failed:', e); }
  setBadgeState('OFF');
}

function setRecordingMode(): void {
  try { chrome.action.setPopup({ popup: POPUP_HTML_PATH }); } catch (e) { logger.warn('setPopup REC failed:', e); }
  setBadgeState('REC');
}

function setErrorMode(): void {
  // ERROR mode keeps the popup accessible so the operator can still
  // attempt SAVE if any data is buffered + see the error copy.
  try { chrome.action.setPopup({ popup: POPUP_HTML_PATH }); } catch (e) { logger.warn('setPopup ERROR failed:', e); }
  setBadgeState('ERROR');
}
// Port from offscreen (D-17). Re-assigned on every (re)connect.
let videoPort: chrome.runtime.Port | null = null;
// Offscreen readiness Promise — set up at module load, resolved on first
// OFFSCREEN_READY message (Pattern 4). startVideoCapture awaits this before
// sending START_RECORDING, so we never lose the popup's transient activation
// to a race with the offscreen bootstrap.
let offscreenReadyResolve: (() => void) | null = null;
let offscreenReady: Promise<void> = new Promise((res) => {
  offscreenReadyResolve = res;
});
// userEvents хранится только в content script
// Для архивации получаем его оттуда

// Ring-buffer helpers (header-pin + age-trim) and the buffer duration
// constant were removed in Plan 01-03 — the buffer now lives in
// src/offscreen/recorder.ts per D-16. Plan 05 completes the SW shrink:
// see deletions below.

// Создание offscreen документа
async function ensureOffscreen() {
  if (offscreenCreated) {
    logger.log('Offscreen already created');
    return;
  }

  try {
    const url = chrome.runtime.getURL('src/offscreen/index.html');
    logger.log('Creating offscreen document at:', url);

    await chrome.offscreen.createDocument({
      url: url,
      reasons: [chrome.offscreen.Reason.DISPLAY_MEDIA],
      justification: 'Continuous screen recording for operator session diagnostics'
    });
    offscreenCreated = true;
    logger.log('Offscreen document created successfully');
  } catch (error) {
    const msg = error instanceof Error ? error.message : String(error);
    if (msg.includes('already exists')) {
      offscreenCreated = true;
      logger.log('Offscreen document already exists');
    } else {
      logger.error('Failed to create offscreen document:', error);
      throw error;
    }
  }
}

/**
 * Open the welcome page on first install (Plan 01-10 D-17-onboarding).
 *
 * The 'D-17-onboarding' suffix disambiguates from D-17-port-lifecycle
 * per CONTEXT.md lines 540-545. Trigger conditions (all must hold):
 *   - details.reason === 'install' (NOT 'update' / 'chrome_update' /
 *     'shared_module_update');
 *   - chrome.storage.local key 'onboarding-completed' NOT === true.
 *
 * Side effects on first install:
 *   - chrome.tabs.create({url: chrome.runtime.getURL('src/welcome/welcome.html')})
 *   - chrome.storage.local.set({'onboarding-completed': true,
 *     'installed-at': Date.now()})
 *
 * Failure mode: any thrown chrome.* call is caught + logged via
 * logger.warn. The welcome tab failing is NOT fatal — the toolbar
 * onClicked path (D-16-toolbar) remains the operator's start path and
 * is independent of the onboarding flow.
 *
 * Architectural note: the fetch of 'onboarding-completed' uses the
 * EXACT string key (no array form) so the unit-test contract in
 * tests/background/onboarding.test.ts Test A's assertion
 * "saw chrome.storage.local.get('onboarding-completed')" holds. The
 * storage-schema cross-version-compat pin (I-02 lesson preserved from
 * prior plan draft) lives in that test.
 */
async function openWelcomeIfFirstInstall(
  details: chrome.runtime.InstalledDetails,
): Promise<void> {
  if (details.reason !== 'install') {
    return;
  }
  try {
    const stored = await chrome.storage.local.get(ONBOARDING_FLAG);
    if (stored[ONBOARDING_FLAG] === true) {
      logger.log('Onboarding already completed; skipping welcome tab.');
      return;
    }
    const url = chrome.runtime.getURL(WELCOME_PATH);
    await chrome.tabs.create({ url });
    await chrome.storage.local.set({
      [ONBOARDING_FLAG]: true,
      [ONBOARDING_INSTALLED_AT]: Date.now(),
    });
    logger.log(
      'Welcome tab opened (D-17-onboarding); onboarding flag set.',
    );
  } catch (err) {
    logger.warn('openWelcomeIfFirstInstall failed:', err);
  }
}

// Outer-bound buffer fetch budget. Larger than the legacy
// BUFFER_FETCH_TIMEOUT_MS (was 2 s; per-port-attempt) because the new
// architecture covers MULTIPLE port-replacement retries inside one outer
// budget. 10 s is generous: the inner per-port encode round-trip is
// still ~100-200 ms; the extra headroom covers up to ~50 reconnect
// cycles before the operator-visible error surfaces.
const BUFFER_FETCH_TIMEOUT_MS = 10_000;

// ─── D-P2-01 Blob URL mint/revoke lifecycle state (P0-6 fix) ──────────
// pendingDownloadUrlResolvers maps a per-mint requestId to the resolver
// of the in-flight downloadArchive's Promise. Mirrors the pendingBuffer
// Requests pattern: the onConnect-level port message sink routes the
// DOWNLOAD_URL response by id so port replacement mid-mint does not
// drop the response.
const pendingDownloadUrlResolvers: Map<string, (url: string) => void> = new Map();
// pendingRevokes maps a chrome.downloads downloadId to the minted blob:URL
// awaiting revocation. Populated when chrome.downloads.download resolves
// with its downloadId; drained by the chrome.downloads.onChanged listener
// when the corresponding state transitions to 'complete' or 'interrupted'.
// The Map is bounded by O(saves-per-session) which is operationally <100
// (T-02-02-02 threat-register entry); SW idle teardown clears it entirely.
const pendingRevokes: Map<number, string> = new Map();
// Outer-bound budget for the offscreen mint round-trip. The bridge is
// purely local (no network), so 5 s is generous — the inner encode +
// post round-trip is typically <100 ms for archives <10 MB.
const BLOB_URL_MINT_TIMEOUT_MS = 5_000;

// Option C: in-flight REQUEST_BUFFER requests keyed by requestId. The
// onConnect-level message sink routes BUFFER -> resolve by id, so port
// replacement (videoPort changes mid-request) does NOT lose the
// response — the offscreen posts BUFFER on the CURRENT port (whichever
// that is) and our sink picks it up regardless of which Port object it
// arrives on.
interface PendingBufferRequest {
  resolve: (resp: VideoBufferResponse) => void;
  hardTimer: ReturnType<typeof setTimeout>;
  requestId: string;
}
const pendingBufferRequests: Map<string, PendingBufferRequest> = new Map();

// Generate a per-request correlation id. Uses crypto.randomUUID when
// available (Chrome 92+ in SW context per
// https://developer.chrome.com/docs/extensions/reference/api/runtime#secure_origin),
// with a Math.random fallback that's still unique enough for in-process
// routing — collisions would require simultaneous in-flight requests
// within the same millisecond on the same SW lifetime, vanishingly
// improbable for this UI flow.
function generateRequestId(): string {
  if (
    typeof crypto !== 'undefined' &&
    typeof crypto.randomUUID === 'function'
  ) {
    return crypto.randomUUID();
  }
  return `req-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}

// Decodes a BUFFER message's wire-format segments into VideoSegment[].
// Extracted from the legacy inline handler so the onConnect sink can
// resolve a pending request without duplicating the decode logic.
function decodeBufferSegments(
  wireSegments: TransferredVideoSegment[],
): VideoSegment[] {
  // WR-07 fix: filter empty wire segments BEFORE base64 decode. An empty
  // wire.data would decode to a zero-byte Blob; the remux pipeline
  // (src/background/webm-remux.ts, Plan 01-08 D-14-remux) would try
  // to parse it via ts-ebml and either fail loudly or emit zero frames,
  // either way wasting a parse cycle. Two passes (filter -> decode ->
  // filter-non-empty) keep the iteration semantics declarative.
  const nonEmptyWires = wireSegments.filter((wire) => {
    const isEmpty = !wire.data || wire.data.length === 0;
    if (isEmpty) {
      logger.warn(
        'Skipping empty wire segment (zero-length base64)',
        'timestamp:', wire.timestamp,
      );
    }
    return !isEmpty;
  });
  const segments: VideoSegment[] = nonEmptyWires.flatMap((wire) => {
    try {
      const blob = base64ToBlob(wire.data, wire.type || VIDEO_MIME_FALLBACK);
      if (blob.size === 0) {
        logger.warn(
          'Skipping segment that decoded to zero bytes',
          'timestamp:', wire.timestamp,
        );
        return [];
      }
      return [{ data: blob, timestamp: wire.timestamp }];
    } catch (err) {
      logger.warn(
        'base64ToBlob failed; skipping segment',
        'timestamp:', wire.timestamp,
        'error:', err,
      );
      return [];
    }
  });
  return segments;
}

// SW-side port host (D-17, RESEARCH.md Pattern 5). The offscreen opens this
// port on bootstrap and reconnects on disconnect. We use it for: (a)
// keepalive traffic (PING/PONG health probe — Option C) — Chrome 110+
// resets the SW idle timer on every port message, AND the PONG reply
// closes the offscreen's health-probe loop; (b) on-demand REQUEST_BUFFER
// round-trip during SAVE_ARCHIVE, routed by requestId so port
// replacement mid-request does not drop the response.
chrome.runtime.onConnect.addListener((port) => {
  // T-1-04 mitigation: only accept ports from this extension.
  if (port.name !== 'video-keepalive') {
    return;
  }
  if (port.sender?.id !== chrome.runtime.id) {
    logger.warn('Rejecting port with mismatched sender:', port.sender?.id);
    port.disconnect();
    return;
  }
  logger.log('Offscreen port connected');
  videoPort = port;
  // CR-02 fix: install a permanent onMessage sink on every accepted port.
  // Chrome 110+ resets the SW idle-timer on any inbound port message, BUT
  // in the field, behaviour has been observed to differ subtly when no
  // listener is attached at all — Chrome may skip the idle-timer reset
  // path entirely on unrouted messages.
  //
  // Option C: this sink ALSO routes BUFFER responses to the matching
  // pending request by requestId (the per-request listener pattern is
  // gone — it could not handle port replacement). And it echoes PONG on
  // every PING so the offscreen's health probe sees life.
  port.onMessage.addListener((msg) => {
    if (typeof msg !== 'object' || msg === null) {
      return;
    }
    const type = (msg as { type?: unknown }).type;
    if (type === 'PING') {
      // Health-probe echo (Option C). Wrapped in try/catch because the
      // port may have been disconnected between the inbound PING and
      // our response — silently drop in that race window.
      try {
        port.postMessage({ type: 'PONG' });
      } catch (err) {
        logger.warn('PONG postMessage failed (port closed):', err);
      }
      return;
    }
    if (type === 'BUFFER') {
      const requestId = (msg as { requestId?: unknown }).requestId;
      if (typeof requestId !== 'string' || requestId.length === 0) {
        // Defense-in-depth: BUFFER without a valid requestId is invalid
        // under the Option C protocol — drop with a warn. (Legacy
        // offscreen code that didn't carry requestId is gone.)
        logger.warn('BUFFER without a valid requestId — dropping');
        return;
      }
      const pending = pendingBufferRequests.get(requestId);
      if (pending === undefined) {
        // Stale BUFFER (request already resolved or timed out). Silently
        // drop — this is the no-cross-talk property the request-id
        // routing guarantees.
        return;
      }
      const wireSegments =
        (msg as { segments?: TransferredVideoSegment[] }).segments ?? [];
      const segments = decodeBufferSegments(wireSegments);
      clearTimeout(pending.hardTimer);
      pendingBufferRequests.delete(requestId);
      pending.resolve({ segments });
      return;
    }
    if (type === 'DOWNLOAD_URL') {
      // D-P2-01: route the offscreen-minted blob:URL back to the
      // in-flight downloadArchive Promise. Mirrors the BUFFER routing
      // above — keyed by requestId so concurrent mints (theoretically
      // possible across two SAVE flows) cannot cross-talk.
      const requestId = (msg as { requestId?: unknown }).requestId;
      if (typeof requestId !== 'string' || requestId.length === 0) {
        logger.warn('DOWNLOAD_URL without a valid requestId — dropping');
        return;
      }
      const resolver = pendingDownloadUrlResolvers.get(requestId);
      if (resolver === undefined) {
        // Stale DOWNLOAD_URL (mint already timed out). Silently drop.
        return;
      }
      pendingDownloadUrlResolvers.delete(requestId);
      const url = (msg as { url?: unknown }).url;
      resolver(typeof url === 'string' ? url : '');
      return;
    }
    // Unknown traffic — drop silently (T-1-04 defense-in-depth).
  });
  port.onDisconnect.addListener(() => {
    logger.log('Offscreen port disconnected; offscreen will reconnect');
    if (videoPort === port) {
      videoPort = null;
    }
  });
  // If there are pending REQUEST_BUFFER requests at the moment this port
  // connects, re-issue them on the fresh port with the SAME requestId.
  // This is the architectural mechanism that retires the H2 silent-drop
  // class — the BUFFER reaches the SW regardless of port-replacement
  // timing. (Note: the FIRST onConnect has pendingBufferRequests.size
  // === 0 so this branch correctly does nothing on bootstrap.)
  if (pendingBufferRequests.size > 0) {
    for (const pending of pendingBufferRequests.values()) {
      try {
        port.postMessage({
          type: 'REQUEST_BUFFER',
          requestId: pending.requestId,
        });
      } catch (err) {
        // The fresh port disconnected synchronously — the outer hard
        // timer will fire and surface the error.
        logger.warn('REQUEST_BUFFER retry post failed:', err);
      }
    }
  }
});

async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
  if (videoPort === null) {
    logger.warn('No offscreen port available; returning empty buffer');
    return { segments: [] };
  }
  const requestId = generateRequestId();
  return new Promise<VideoBufferResponse>((resolve) => {
    const hardTimer = setTimeout(() => {
      pendingBufferRequests.delete(requestId);
      // Outer hard-timeout: covers EVERY retry across port replacements
      // (the legacy per-port BUFFER_FETCH_TIMEOUT_MS was 2 s per
      // attempt — too tight to retry across a reconnect). 10 s is
      // generous; the inner round-trip is still ~100-200 ms.
      logger.warn(
        `Buffer fetch outer timeout (${BUFFER_FETCH_TIMEOUT_MS} ms) — no BUFFER for requestId ${requestId}`,
      );
      resolve({ segments: [] });
    }, BUFFER_FETCH_TIMEOUT_MS);
    pendingBufferRequests.set(requestId, {
      resolve,
      hardTimer,
      requestId,
    });
    try {
      videoPort?.postMessage({ type: 'REQUEST_BUFFER', requestId });
    } catch (err) {
      // The current port disconnected synchronously. Don't resolve here
      // — the offscreen's reconnect will fire a fresh onConnect, the
      // sink will detect the in-flight request, and the retry path will
      // re-post REQUEST_BUFFER on the new port.
      logger.warn('Initial REQUEST_BUFFER post failed (port disconnected):', err);
    }
  });
}

// Начало записи видео
async function startVideoCapture() {
  if (isRecording) {
    logger.log('Video recording already active');
    return;
  }

  try {
    // Plan 01-09 D-01 cleanup gap (debug session
    // `01-09-notification-start-no-active-tab`, 2026-05-20):
    // The legacy chrome.tabs.query({ active: true, currentWindow: true })
    // here was load-bearing in the pre-D-01 chrome.tabCapture era but
    // is functionally dead post-D-01 — capture is whole-desktop via
    // getDisplayMedia in offscreen and the SW-side start path needs
    // no tab reference. The query also failed for chrome.notifications
    // .onClicked callers (no activeTab grant + no `tabs` permission →
    // tab.url undefined → "No active tab found" throw) so the onStartup
    // notification CTA was silently broken. captureScreenshot +
    // saveArchive retain their own genuine tab queries (tab.windowId
    // for captureVisibleTab, tab.id for content-script sendMessage).
    logger.log('Starting video capture (whole-desktop via getDisplayMedia in offscreen per D-01)');

    // Создаём offscreen документ (с reason из D-02)
    await ensureOffscreen();
    // Ждём, пока offscreen зарегистрирует свой onMessage listener
    // (RESEARCH.md Pattern 4). Иначе гонка: START_RECORDING летит раньше,
    // чем offscreen готов его принять, и Chrome бросает "Receiving end
    // does not exist".
    await offscreenReady;

    // Просим offscreen запустить запись — getDisplayMedia вызывается там
    // (D-01: больше нет SW-side stream-id юзаства).
    logger.log('Sending START_RECORDING to offscreen...');
    try {
      await chrome.runtime.sendMessage({
        type: 'START_RECORDING'
      });
      logger.log('START_RECORDING sent successfully');
    } catch (msgError) {
      logger.error('Failed to send START_RECORDING:', msgError);
      throw msgError;
    }

    isRecording = true;
    // Plan 01-09: transition badge + popup mode to REC so the toolbar
    // click opens the SAVE-only popup (not re-fires onClicked).
    setRecordingMode();
    logger.log('Video recording started successfully');

  } catch (error) {
    logger.error('Failed to start video capture:', error);
    isRecording = false;
    // Plan 01-09: transition to ERROR state so the badge surfaces the
    // failure visually and the popup is reachable for any next-action UI.
    setErrorMode();
    throw error;
  }
}

// Keepalive теперь обеспечивается long-lived портом offscreen→SW (D-17/D-18).
// Старая alarms-based реализация удалена: alarm callbacks не сбрасывали SW idle
// timer (audit P1 #8), а порт сбрасывает таймер на каждое сообщение.

// Получение видеобуфера — port-based (getVideoBufferFromOffscreen объявлен
// выше). Старый синхронный SW-локальный буфер удалён в Task 1 этого плана.

// Получение скриншота активной вкладки
async function captureScreenshot(): Promise<Blob> {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });

  if (!tab.id) {
    throw new Error('No active tab');
  }

  // Проверяем кэш и лимит частоты (максимум 1 скриншот в 2 секунды)
  const now = Date.now();
  if (cachedScreenshot && (now - lastScreenshotTime) < 2000) {
    logger.log('Using cached screenshot');
    return cachedScreenshot;
  }

  // Делаем новый скриншот
  const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });

  // Конвертируем data URL в Blob
  const response = await fetch(dataUrl);
  cachedScreenshot = await response.blob();
  lastScreenshotTime = now;

  return cachedScreenshot;
}

// mergeVideoSegments (D-13 file-concat) retired in Plan 01-08 (D-14-remux):
// see src/background/webm-remux.ts for the single-EBML remux path.
// The concat-of-self-contained-WebM-segments approach produced a
// multi-EBML-header file that mpv / Chrome / ffprobe truncated to
// the first segment's local Info.Duration ~9.94 s; the remux path
// emits a single-EBML WebM whose Info.Duration covers the full ~30 s
// timeline. D-13's recorder-side restart-segments lifecycle is
// preserved — only the merge step is replaced.

// Создание архива
async function createArchive(
  videoBufferResponse: VideoBufferResponse,
  rrwebEvents: unknown[],
  userEvents: unknown[],
  screenshot: Blob
): Promise<Blob> {
  logger.log('=== Creating archive ===');
  logger.log(`Events to include: ${rrwebEvents.length} rrweb, ${userEvents.length} user`);

  const zip = new JSZip();

  // Добавляем видео (D-13: каждая запись — самодостаточный WebM-сегмент).
  //
  // Option C (debug session empty-archive-port-race): the upstream
  // silent-skip branch is GONE. Shipping a zip with no video defeats the
  // entire purpose of the operator-save flow (Phase 1 goal); the operator
  // must see a clear failure instead of receiving a 88 KB archive with no
  // last_30sec.webm. saveArchive's catch translates the throw into the
  // {success: false, error: 'empty-video-buffer'} response shape the
  // popup already handles via the RECORDING_ERROR surface.
  if (videoBufferResponse.segments.length === 0) {
    throw new EmptyVideoBufferError(
      'no video segments available — buffer fetch returned empty (port replacement timed out, or recorder never started)',
    );
  }
  // Plan 01-08 D-14-remux: replaces the retired mergeVideoSegments()
  // file-concat with the new single-EBML WebM remux. Async now —
  // ts-ebml parse + webm-muxer write happen on the SW thread.
  const videoBlob = await remuxSegments(videoBufferResponse.segments);
  if (videoBlob.size === 0) {
    throw new EmptyVideoBufferError(
      `remuxed video blob is zero bytes (segment count=${videoBufferResponse.segments.length})`,
    );
  }
  zip.file('video/last_30sec.webm', videoBlob);
  logger.log(`✓ Added video (remuxed): ${videoBlob.size} bytes`);

  // Добавляем rrweb события
  const rrwebJson = JSON.stringify(rrwebEvents, null, 2);
  zip.file('rrweb/session.json', rrwebJson);
  if (rrwebEvents.length > 0) {
    logger.log(`✓ Added rrweb events: ${rrwebEvents.length} events, ${rrwebJson.length} bytes`);
  } else {
    logger.warn('✗ rrweb session.json created but EMPTY (content script may not be running)');
  }

  // Добавляем пользовательские события
  const eventsJson = JSON.stringify(userEvents, null, 2);
  zip.file('logs/events.json', eventsJson);
  if (userEvents.length > 0) {
    logger.log(`✓ Added user events: ${userEvents.length} events, ${eventsJson.length} bytes`);
  } else {
    logger.warn('✗ logs/events.json created but EMPTY (no user interactions detected)');
  }

  // Добавляем скриншот
  zip.file('screenshot.png', screenshot);
  logger.log('✓ Added screenshot');

  // Добавляем метаданные
  // IN-01 fix: read version from manifest at runtime instead of the
  // hardcoded '1.0.0'. Previously the metadata would silently lie about
  // the running version once manifest.json bumps to 1.0.1+. The Chrome
  // runtime API is always available in the SW context, so no fallback
  // is needed.
  //
  // Plan 02-03 D-P2-02 + D-P2-03 amendment (2026-05-20; closes audit P1 #10):
  //   - `url: string` REPLACED by `urls: string[]` sourced from the
  //     tab-url tracker (chrome.tabs.onActivated + onUpdated event-driven
  //     accumulation + snapshotOpenTabs SAVE-time defensive enumeration
  //     via chrome.tabs.query({})).
  //   - `schemaVersion: '2'` ADDED to mark the schema-breaking cutover.
  //   - Field-emission order follows the SessionMetadata interface line
  //     order — TypeScript preserves object-literal insertion order, and
  //     JSON.stringify emits in insertion order per ECMA-262.
  //
  // DEC-011 Amendment 1: with `tabs` permission granted, snapshot every
  // currently-open tab's URL at SAVE time as a defensive fallback. This
  // captures tabs the operator OPENED during the 30 s window but never
  // activated (so the onActivated listener never fired for them). Dedup
  // + order preservation is handled by the tracker itself.
  try {
    await snapshotOpenTabs();
  } catch (err) {
    logger.warn('snapshotOpenTabs failed at SAVE time (continuing with tracker state):', err);
  }
  const urls = getTabUrlsSeen();
  if (urls.length === 0) {
    // Meaningful state per F2 (plan-checker iteration 1): whole-desktop
    // recording with NO browser tabs open at SAVE time. The empty array
    // IS the canonical representation — NO fake extension-origin URL
    // inserted. tests/background/meta-json-urls-schema.test.ts pins this
    // contract.
    logger.warn(
      'createArchive: tabUrlsSeen is empty after snapshotOpenTabs — emitting meta.urls=[] (whole-desktop session with no browser tabs)',
    );
  }
  const manifest = chrome.runtime.getManifest();
  const metadata: SessionMetadata = {
    schemaVersion: '2',
    timestamp: new Date().toISOString(),
    urls,
    userAgent: navigator.userAgent,
    extensionVersion: manifest.version,
    videoBufferSeconds: 30,
    logDurationMinutes: 10,
    totalEvents: rrwebEvents.length + userEvents.length
  };

  zip.file('meta.json', JSON.stringify(metadata, null, 2));
  logger.log('✓ Added metadata');

  // Генерируем архив
  const archiveBlob = await zip.generateAsync({ type: 'blob' });
  logger.log(`✓ Archive created: ${archiveBlob.size} bytes`);

  return archiveBlob;
}

// Скачивание архива (D-P2-01: offscreen-minted blob: URL pipeline; P0-6 fix)
//
// Architectural triangle: SW packages zip → SW asks offscreen to mint URL
// (CREATE_DOWNLOAD_URL with base64 archive bytes) → offscreen mints via
// URL.createObjectURL (SW lacks it per DEC-006) → offscreen replies
// DOWNLOAD_URL{url} → SW calls chrome.downloads.download → onChanged
// fires 'complete'/'interrupted' → SW asks offscreen to revoke
// (REVOKE_DOWNLOAD_URL). The base64 wire-format reuses the D-12
// precedent from src/shared/binary.ts.
//
// NO FALLBACK to the legacy data: URL pathway: real operator archives
// (5-10 MB) exceed Chrome's ~2 MB data-URL cap and would silently fail
// with a 'Network error' download (audit P0-6). The legacy encoding
// chain (blobToBase64 + chrome.downloads.download(`data:...`)) is gone.
// On any failure (mint timeout, empty url, port unavailable, non-blob:
// prefix) we throw a typed Error that routes through saveArchive's
// catch block into the RECORDING_ERROR channel — operator gets a
// visible failure, not a silently corrupted archive.
async function downloadArchive(archiveBlob: Blob) {
  const now = new Date();
  const dateStr = now.toISOString().replace(/[:.]/g, '-').split('T')[0];
  const timeStr = now.toTimeString().split(' ')[0].replace(/:/g, '-');

  const filename = `session_report_${dateStr}_${timeStr}.zip`;

  logger.log(`Downloading archive: ${filename} (${archiveBlob.size} bytes)`);

  if (videoPort === null) {
    throw new Error('blob-url-mint-failed: offscreen port unavailable');
  }

  // Encode the archive bytes for the SW→offscreen wire (D-12 base64
  // precedent — chrome.runtime.Port JSON-serializes payloads and Blobs
  // arrive as empty objects without this transform).
  const dataBase64 = await blobToBase64(archiveBlob);
  const requestId = generateRequestId();
  const urlPromise = new Promise<string>((resolve) => {
    pendingDownloadUrlResolvers.set(requestId, resolve);
  });
  try {
    videoPort.postMessage({
      type: 'CREATE_DOWNLOAD_URL',
      requestId,
      dataBase64,
      mimeType: 'application/zip',
    });
  } catch (err) {
    // Port disconnected synchronously between the null-check and post.
    // Clean up the resolver entry so it doesn't leak; surface a typed
    // error so saveArchive's catch routes it to RECORDING_ERROR.
    pendingDownloadUrlResolvers.delete(requestId);
    throw new Error(`blob-url-mint-failed: CREATE_DOWNLOAD_URL post threw: ${String(err)}`);
  }
  const timeoutPromise = new Promise<string>((_, reject) => {
    setTimeout(
      () => reject(new Error('blob-url-mint-timeout')),
      BLOB_URL_MINT_TIMEOUT_MS,
    );
  });
  let url: string;
  try {
    url = await Promise.race([urlPromise, timeoutPromise]);
  } catch (err) {
    // Timeout fired before the offscreen responded. Drain the resolver
    // map entry; the late-arriving DOWNLOAD_URL will be silently dropped
    // by the onConnect sink (stale-id path).
    pendingDownloadUrlResolvers.delete(requestId);
    throw err;
  }
  if (url === '') {
    throw new Error('blob-url-mint-failed: offscreen returned empty url');
  }
  // T-02-02-03 mitigation (defense-in-depth): reject any URL that does
  // not have the blob: scheme. The offscreen is same-extension origin
  // (sender-id-checked) and the WHATWG URL spec guarantees
  // URL.createObjectURL emits blob: only — this guard catches future
  // regressions / hostile-peer-on-shared-port scenarios.
  if (!url.startsWith('blob:')) {
    throw new Error(`blob-url-mint-failed: offscreen returned non-blob: url '${url.substring(0, 40)}...'`);
  }

  const downloadId = await chrome.downloads.download({
    url,
    filename,
    saveAs: false,
  });
  if (typeof downloadId === 'number') {
    // Track the (downloadId → url) pair so the chrome.downloads.onChanged
    // listener (registered below) can dispatch the revoke when the
    // download reaches a terminal state.
    pendingRevokes.set(downloadId, url);
  }
  logger.log(`Archive download started: id=${downloadId}, blob-url=${url.substring(0, 30)}...`);
}

// Сохранение архива (полный процесс)
//
// Plan 01-09 Amendment 3 (2026-05-19, debug session
// 01-09-save-does-not-stop-recording): REVERSES the prior Amendment 2
// save-stops-recording fix. SAVE_ARCHIVE creates a new zip but does
// NOT stop the recorder — per operator UX iteration preferring the
// original "continuous capture / always-on safety net" charter
// (`Тз расширение фаза1.md`). 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
//      through setIdleMode (Bug B branch — preserved).
//   2. Browser closes (SW + offscreen torn down).
//   3. Extension uninstalled.
//
// Operator workflow: hit a bug → click SAVE (zip lands) → keep working
// → the next bug is also captured because the ring buffer keeps
// filling. Contract pinned by
// `tests/background/save-archive-does-not-stop-recording.test.ts` and
// Plan 01-13 harness assertion A14 (both inverted from the prior
// Amendment 2 contract).
async function saveArchive() {
  try {
    logger.log('Starting archive save process');

    // Получаем текущую активную вкладку
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });

    if (!tab.id) {
      throw new Error('No active tab found');
    }

    logger.log(`Using tab ${tab.id} for archive, url: ${tab.url}`);

    // Получаем скриншот
    logger.log('Capturing screenshot...');
    const screenshot = await captureScreenshot();

    // Получаем видео буфер из offscreen через long-lived port (D-17)
    const videoBufferResp = await getVideoBufferFromOffscreen();
    logger.log(`Video buffer: ${videoBufferResp.segments.length} segments`);

    // Получаем rrweb события от content script
    logger.log(`Requesting rrweb events from content script on tab ${tab.id} (${tab.url})...`);

    let rrwebEvents: unknown[] = [];
    let userEvents: unknown[] = [];

    try {
      logger.log(`Sending GET_RRWEB_EVENTS message to tab ${tab.id}...`);
      const response: { events?: unknown[]; userEvents?: unknown[] } | undefined =
        await chrome.tabs.sendMessage(tab.id, { type: 'GET_RRWEB_EVENTS' });

      logger.log(`Got response from tab ${tab.id}:`, response);

      rrwebEvents = response?.events ?? [];
      userEvents = response?.userEvents ?? [];

      logger.log(`✓ Received ${rrwebEvents.length} rrweb events, ${userEvents.length} user events`);

      // Логируем первые несколько событий для отладки
      if (rrwebEvents.length > 0) {
        logger.log('First rrweb event sample:', JSON.stringify(rrwebEvents[0]).substring(0, 200) + '...');
      }
      if (userEvents.length > 0) {
        logger.log('First user event:', userEvents[0]);
      }

    } catch (messageError) {
      logger.warn('✗ Failed to get events from content script:', messageError);
      logger.warn('This may happen on special pages (chrome://, about:blank) or if content script is not injected');
      logger.warn('Check if content script is running on the active tab (open DevTools Console on the page)');
      // Продолжаем без событий, если контент-скрипт недоступен
    }

    // Создаем архив
    const archiveBlob = await createArchive(
      videoBufferResp,
      rrwebEvents,
      userEvents,
      screenshot
    );

    // Скачиваем
    await downloadArchive(archiveBlob);

    logger.log('Archive save completed');
    return { success: true };

  } catch (error) {
    logger.error('Failed to save archive:', error);
    // Option C: the empty-video failure is operator-visible. Emit
    // RECORDING_ERROR so the popup's existing handler can surface it
    // (same channel codec-unsupported, user-cancelled, etc. ride).
    // Other createArchive failures (zip libs, JSZip internals) stay
    // SW-side only — they're not actionable by the operator.
    if (error instanceof EmptyVideoBufferError) {
      try {
        chrome.runtime.sendMessage({
          type: 'RECORDING_ERROR',
          error: error.code,
        });
      } catch (sendErr) {
        // Best-effort notification — if the popup is closed we have
        // nothing else to do.
        logger.warn('Failed to broadcast RECORDING_ERROR:', sendErr);
      }
    }
    return { success: false, error };
  }
  // Plan 01-09 Amendment 3 (2026-05-19): NO `finally` block here. The
  // prior Amendment 2 finally block (STOP_RECORDING dispatch + isRecording=false
  // + setIdleMode) is REMOVED. SAVE_ARCHIVE creates a zip and returns;
  // the recorder and state machine stay in REC. See the function-level
  // docstring above for the full charter rationale.
}

// checkPermissions / requestPermissions удалены: старая permission
// больше не нужна (D-A6 — заменена на desktopCapture в manifest), а
// getDisplayMedia не требует runtime-разрешения — нужен только user gesture.
// REQUEST_PERMISSIONS теперь просто запускает запись и возвращает granted=true.

// Обработка сообщений
chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => {
  // T-1-NEW-05-01 mitigation: only accept onMessage traffic from this
  // extension (popup, content script, offscreen). External callers (other
  // extensions, web pages) are silently dropped.
  if (sender.id !== chrome.runtime.id) {
    logger.warn('Rejecting message with mismatched sender:', sender.id);
    return false;
  }
  logger.log('Received message:', message.type, message);

  switch (message.type) {
    case 'REQUEST_PERMISSIONS':
      // Под getDisplayMedia (D-01) runtime-permission проверять нечего —
      // браузер сам покажет picker по user gesture из popup. Просто
      // запускаем запись и подтверждаем popup-у.
      (async () => {
        try {
          if (!isRecording) {
            await startVideoCapture();
          }
          sendResponse({ granted: true });
        } catch (error) {
          logger.error('startVideoCapture failed:', error);
          sendResponse({ granted: false });
        }
      })();
      return true;

    case 'GET_VIDEO_BUFFER':
      getVideoBufferFromOffscreen().then((resp) => sendResponse(resp));
      return true;

    case 'SAVE_ARCHIVE':
      saveArchive().then(result => {
        sendResponse(result);
      });
      return true;

    case 'OFFSCREEN_READY':
      logger.log('OFFSCREEN_READY received');
      offscreenReadyResolve?.();
      offscreenReadyResolve = null;
      return false;

    case 'RECORDING_ERROR':
      // Plan 01-09 — the offscreen recorder broadcasts this on capture
      // failure (codec missing, picker cancelled, wrong-display-surface,
      // mid-record stream end, etc.) AND on operator-initiated stop-sharing
      // (src/offscreen/recorder.ts: onUserStoppedSharing emits
      // RECORDING_ERROR{error:'user-stopped-sharing'} after resetBuffer +
      // track release).
      //
      // Bug B (debug 01-09-recovery-flow) conditional routing: the
      // operator-initiated stop is NOT an error condition, it is a
      // deliberate end-of-session signal. Routing it through setErrorMode
      // would (a) leave the popup pinned to src/popup/index.html (SAVE-only)
      // so chrome.action.onClicked cannot re-fire — the popup wins the
      // toolbar click forever, and (b) paint the badge yellow as if a
      // capture failure occurred. Both lock the operator out of restart.
      // Route 'user-stopped-sharing' through setIdleMode instead: popup
      // empties (re-enabling onClicked-driven restart), badge returns to
      // OFF (resetBuffer has already cleared the offscreen buffer so SAVE
      // mode would be meaningless). No recovery notification: the operator
      // performed the stop deliberately; surfacing a notification would
      // be UX noise.
      // All other error codes preserve the original setErrorMode + recovery
      // notification routing (defensive fallback for genuine capture
      // failures — codec-unsupported, wrong-display-surface, capture-failed,
      // permission-denied, empty-video-buffer, unknown).
      logger.warn('RECORDING_ERROR received:', message);
      {
        // Narrow `message` to read the optional `error` payload. The
        // canonical Message interface (src/shared/types.ts) does not
        // typify it (Message has type/data/tabId only); the offscreen
        // recorder emits the extra `error` field as part of the
        // RECORDING_ERROR wire shape — read it via a minimal cast that
        // keeps us off `as any`.
        const errorCode = (message as unknown as { error?: unknown }).error;
        if (errorCode === 'user-stopped-sharing') {
          isRecording = false;
          setIdleMode();
        } else {
          setErrorMode();
          try {
            const recoveryId = NOTIFICATION_RECOVERY_PREFIX + Date.now();
            chrome.notifications.create(recoveryId, {
              type: 'basic',
              iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH),
              title: i18nMessage('extName', NOTIF_EXTNAME_FALLBACK),
              message: i18nMessage('notifRecovery', NOTIF_RECOVERY_FALLBACK),
              priority: 1,
            });
          } catch (e) {
            logger.warn('Recovery notification create failed:', e);
          }
        }
      }
      return false;

    // Legacy chunk-streaming and IndexedDB save/load handlers were removed
    // in Plan 01-03:
    //   - the offscreen recorder now owns the buffer (D-16);
    //   - chunks no longer travel via chrome.runtime.sendMessage (D-19);
    //   - SW-side IDB plumbing was the audit P0 #2 broken path.
    // The IDB helpers were only reachable from those deleted cases.
    // Plan 05 finishes the SW shrink (see deletions above).

    default:
      logger.warn('Unknown message type:', message.type);
      return false;
  }
});

// Инициализация
async function initialize() {
  logger.log('Service Worker initializing');
  // Audit P1 #8: after the SW respawns (e.g. after Chrome wakes it from idle),
  // the offscreen document may still exist while our offscreenCreated flag
  // resets to false. Ask Chrome the ground truth so we don't end up trying
  // to createDocument over an existing one. Cheap and idempotent.
  try {
    if (typeof chrome.offscreen?.hasDocument === 'function') {
      const exists = await chrome.offscreen.hasDocument();
      if (exists) {
        offscreenCreated = true;
        logger.log('Existing offscreen document detected on SW init');
        // CR-03 fix: handshake deadlock after SW respawn.
        // OFFSCREEN_READY is fired by the offscreen exactly once at its
        // bootstrap (recorder.ts line ~452). If the SW is evicted (~30 s
        // idle) while the offscreen document persists, the next SW lifetime
        // creates a fresh `offscreenReady` Promise (line 33) and waits on
        // it forever — the offscreen has no signal to re-emit, and
        // startVideoCapture() hangs at `await offscreenReady`.
        // Resolve readiness immediately when we detect a pre-existing
        // offscreen: it MUST have completed its bootstrap before being
        // observable via hasDocument().
        offscreenReadyResolve?.();
        offscreenReadyResolve = null;
        logger.log('Resolving offscreenReady immediately — offscreen survived a prior SW lifetime');
      }
    }
  } catch (err) {
    logger.warn('chrome.offscreen.hasDocument check failed:', err);
  }
  // Plan 01-09: enter idle mode at SW init — sets popup to '' so the
  // first toolbar click fires onClicked directly (skipping the popup),
  // and paints the OFF badge so the operator sees recording state at
  // a glance.
  setIdleMode();
  logger.log('Service Worker initialized');
}

// ─── Plan 01-09 listener registrations ───────────────────────────────
// All listener registrations are wrapped in try/catch so SW module load
// stays defensively resilient when the chrome.action / chrome.notifications
// / chrome.runtime.onStartup surfaces are absent or partially stubbed
// (some unit test contexts only stub the runtime/tabs/offscreen subset).
// In production all three surfaces are guaranteed by the MV3 manifest.

// chrome.action.onClicked: fires ONLY when setPopup is '' (idle mode).
// On click, if we are not already recording, start the capture. The
// activation gesture is the toolbar click itself — getDisplayMedia
// accepts that as a valid user gesture per the W3C Screen Capture spec.
try {
  chrome.action.onClicked.addListener(async () => {
    if (isRecording) {
      logger.log('Toolbar onClicked while already recording — no-op');
      return;
    }
    try {
      await startVideoCapture();
    } catch (err) {
      logger.warn('toolbar-onClicked start failed:', err);
    }
  });
} catch (e) {
  logger.warn('chrome.action.onClicked.addListener failed:', e);
}

// chrome.runtime.onStartup: fires once when a new browser session starts
// (NOT on extension install — that's onInstalled). Show an OS-level
// notification inviting the operator to start a recording.
try {
  chrome.runtime.onStartup.addListener(() => {
    setIdleMode();
    try {
      const notificationId = NOTIFICATION_STARTUP_PREFIX + Date.now();
      chrome.notifications.create(notificationId, {
        type: 'basic',
        iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH),
        title: i18nMessage('extName', NOTIF_EXTNAME_FALLBACK),
        message: i18nMessage('notifStartupCta', NOTIF_STARTUP_CTA_FALLBACK),
        priority: 1,
      });
    } catch (e) {
      logger.warn('Startup notification create failed:', e);
    }
  });
} catch (e) {
  logger.warn('chrome.runtime.onStartup.addListener failed:', e);
}

// chrome.notifications.onClicked: T-1-09-01 mitigation — only accept ids
// in our 'mokosh-' namespace. chrome.notifications ids are per-extension
// scoped at the OS level, but the prefix check is defense in depth.
try {
  chrome.notifications.onClicked.addListener((notificationId) => {
    if (!notificationId.startsWith('mokosh-')) return;
    try {
      chrome.notifications.clear(notificationId);
    } catch (e) {
      logger.warn('notifications.clear failed:', e);
    }
    // The notification click is itself an activation gesture, so
    // startVideoCapture can call getDisplayMedia successfully from here.
    startVideoCapture().catch((err) => {
      logger.warn('notification-triggered start failed:', err);
    });
  });
} catch (e) {
  logger.warn('chrome.notifications.onClicked.addListener failed:', e);
}

// Plan 02-03 D-P2-02 — tab-url tracker bootstrap.
// Registers chrome.tabs.onActivated + chrome.tabs.onUpdated listeners that
// maintain an internal Set of URLs observed during the SW's lifetime.
// Feeds meta.urls in createArchive (closes audit P1 #10). Defensive
// try/catch matches the surrounding chrome.* listener registration
// pattern; idempotency is handled by the tracker module itself (an
// initialized flag prevents double-registration if some future caller
// invokes initTabUrlTracker more than once).
try {
  initTabUrlTracker();
} catch (e) {
  logger.warn('initTabUrlTracker failed:', e);
}

// chrome.downloads.onChanged: D-P2-01 (P0-6 fix) — revoke-on-terminal-state.
// Closes the URL.revokeObjectURL lifecycle by routing terminal download
// state transitions (`complete` / `interrupted`) into a REVOKE_DOWNLOAD_URL
// port message back to the offscreen document (which is the URL minting
// origin per DEC-006). On benign races (offscreenPort null at revoke time,
// e.g. SW respawn between download start and completion) the URL leaks in
// offscreen until the document is torn down — bounded per-session,
// acceptable per T-02-02-02 threat-register entry (chrome-extension://
// scoped, never exposed to web pages).
try {
  if (chrome.downloads?.onChanged?.addListener !== undefined) {
    chrome.downloads.onChanged.addListener((delta) => {
      if (delta.state === undefined) return;
      const newState = delta.state.current;
      if (newState !== 'complete' && newState !== 'interrupted') return;
      const url = pendingRevokes.get(delta.id);
      if (url === undefined) return;
      pendingRevokes.delete(delta.id);
      if (videoPort !== null) {
        try {
          videoPort.postMessage({ type: 'REVOKE_DOWNLOAD_URL', url });
          logger.log(`Dispatched REVOKE_DOWNLOAD_URL for downloadId=${delta.id}`);
        } catch (err) {
          logger.warn('REVOKE_DOWNLOAD_URL post failed:', err);
        }
      } else {
        logger.warn(
          `videoPort null at revoke time; url ${url.substring(0, 30)}... leaks until offscreen teardown`,
        );
      }
    });
  }
} catch (e) {
  logger.warn('chrome.downloads.onChanged.addListener failed:', e);
}

// Запуск при установке
chrome.runtime.onInstalled.addListener((details) => {
  logger.log('Extension installed/updated:', details.reason);
  // RESEARCH.md Runtime State Inventory — clean up orphaned IndexedDB from
  // pre-Phase-01 builds. Idempotent: no-op if DB never existed.
  // T-1-NEW-05-02 mitigation.
  try {
    indexedDB.deleteDatabase('VideoRecorderDB');
    logger.log('Cleaned up orphaned VideoRecorderDB (if present)');
  } catch (e) {
    logger.warn('IDB cleanup failed:', e);
  }
  initialize();
  // Plan 01-10 D-17-onboarding: open welcome tab on first install.
  // Fire-and-forget — the helper logs its own errors and rejected
  // promises are caught at the .catch boundary so they cannot escape
  // the synchronous listener. The toolbar onClicked start path
  // (D-16-toolbar) is independent of this flow.
  openWelcomeIfFirstInstall(details).catch((err) => {
    logger.warn('openWelcomeIfFirstInstall threw:', err);
  });
});

// Запуск при старте Service Worker
initialize();
