import browser from "webextension-polyfill";
import type { Runtime } from "webextension-polyfill";
import type { ReportDetail } from "@ltt/shared";
import { submitNoteReport, submitRecordingReport } from "./api";
import { getSettings, saveSettings, login, logout, getHttpClient, panelBase } from "./settings";
import { buildGif, buildCursorGif, type VideoFrame } from "./gif";
import type {
  BackgroundResponse,
  RecordEvent,
  RecorderState,
} from "../lib/messages";

const OVERLAY_ACTIVE = new Set<number>();

// debug ring buffer (inspected from the e2e harness / DevTools)
const DEBUG_LOG: string[] = [];
function debugLog(line: string) {
  DEBUG_LOG.push(`${new Date().toISOString()} ${line}`);
  if (DEBUG_LOG.length > 50) DEBUG_LOG.shift();
}

// ---------- recorder state (lives in the worker; survives page navigations) ----------

interface RecorderBuffer {
  recording: boolean;
  startedAt: number | null;
  events: RecordEvent[];
  pageUrl: string | null;
  pageTitle: string | null;
  environment: Record<string, unknown> | null;
  /** screenshots captured for click events, keyed by event index */
  screenshots: Map<number, string>;
  lastUrl: string | null;
  /** sampled cursor positions: {at, x, y} with client timestamps */
  moveTrack: { at: number; x: number; y: number }[];
  /** screencast frames captured while recording (bounded, decimated on overflow) */
  video: VideoFrame[];
  /** false once the debugger detaches (DevTools opened, tab navigated away) */
  videoActive: boolean;
  lastVideoAt: number;
  consoleCount: number;
}

const recorders = new Map<number, RecorderBuffer>();

function recorder(tabId: number): RecorderBuffer {
  let buffer = recorders.get(tabId);
  if (!buffer) {
    buffer = {
      recording: false,
      startedAt: null,
      events: [],
      pageUrl: null,
      pageTitle: null,
      environment: null,
      screenshots: new Map(),
      lastUrl: null,
      moveTrack: [],
      video: [],
      videoActive: false,
      lastVideoAt: 0,
      consoleCount: 0,
    };
    recorders.set(tabId, buffer);
  }
  return buffer;
}

async function setUiHidden(tabId: number, hidden: boolean): Promise<void> {
  // content script may be absent (chrome-internal pages) — ignore failures
  await browser.tabs.sendMessage(tabId, { type: "set_ui_hidden", hidden }).catch(() => {});
}

async function captureVisibleTab(tabId: number): Promise<string> {
  const tab = await browser.tabs.get(tabId);
  // hide the plugin's own UI so it never lands in the screenshot
  await setUiHidden(tabId, true);
  try {
    // let the compositor repaint the page without the overlay
    await new Promise((resolve) => setTimeout(resolve, 150));
    // the first argument of captureVisibleTab is a *window* id
    return (await browser.tabs.captureVisibleTab(tab.windowId ?? undefined, { format: "png" })) as string;
  } finally {
    await setUiHidden(tabId, false);
  }
}

async function captureForEvent(tabId: number, eventIndex: number): Promise<void> {
  try {
    const dataUrl = await captureVisibleTab(tabId);
    recorder(tabId).screenshots.set(eventIndex, dataUrl);
    debugLog(`event screenshot ${eventIndex} captured`);
  } catch (error) {
    // tab may be inactive or chrome-internal — skip the screenshot
    debugLog(`event screenshot ${eventIndex} failed: ${error instanceof Error ? error.message : String(error)}`);
  }
}

function ensureRecordingFreshness(buffer: RecorderBuffer) {
  // drop stale buffers older than 2 hours
  if (buffer.startedAt && Date.now() - buffer.startedAt > 2 * 60 * 60 * 1000) {
    buffer.recording = false;
    buffer.events = [];
    buffer.startedAt = null;
  }
}

async function recorderStart(tabId: number): Promise<RecorderState> {
  const buffer = recorder(tabId);
  const tab = await browser.tabs.get(tabId);
  buffer.recording = true;
  buffer.startedAt = Date.now();
  buffer.events = [];
  buffer.screenshots = new Map();
  buffer.moveTrack = [];
  buffer.video = [];
  buffer.videoActive = false;
  buffer.lastVideoAt = 0;
  buffer.consoleCount = 0;
  buffer.pageUrl = tab.url ?? null;
  buffer.pageTitle = tab.title ?? null;
  buffer.lastUrl = tab.url ?? null;
  buffer.environment = null; // content script supplies it with the first event
  // continuous screen video (Chrome only; falls back to per-event captures)
  buffer.videoActive = await startScreenVideo(tabId);
  return recorderStateOf(buffer);
}

function recorderStateOf(buffer: RecorderBuffer): RecorderState {
  return {
    recording: buffer.recording,
    stepCount: buffer.events.length,
    startedAt: buffer.startedAt,
  };
}

/** Builds the persisted mouse track: offsets from recording start, capped. */
function buildMouseTrack(buffer: RecorderBuffer): { viewport: { w: number; h: number }; points: { t: number; x: number; y: number }[] } | null {
  if (!buffer.moveTrack.length || !buffer.startedAt) return null;
  const viewport = (buffer.environment?.viewport as { w?: number; h?: number } | undefined) ?? null;
  if (!viewport?.w || !viewport?.h) return null;
  let points = buffer.moveTrack.map((p) => ({
    t: Math.max(p.at - buffer.startedAt!, 0),
    x: p.x,
    y: p.y,
  }));
  // decimate very long tracks instead of dropping them
  while (points.length > 5000) points = points.filter((_, i) => i % 2 === 0);
  return { viewport: { w: viewport.w, h: viewport.h }, points };
}

async function recorderStop(tabId: number): Promise<RecorderState> {
  const buffer = recorder(tabId);
  ensureRecordingFreshness(buffer);
  if (!buffer.recording) return recorderStateOf(buffer);
  // the screencast must stop before anything else can race its frames
  void stopScreenVideo(tabId);
  buffer.videoActive = false;

  // collect the tail of the cursor track before anything else can race it
  const flushed = (await browser.tabs.sendMessage(tabId, { type: "recorder_flush_moves" }).catch(() => null)) as
    | { points?: { at: number; x: number; y: number }[] }
    | null;
  if (flushed?.points?.length) buffer.moveTrack.push(...flushed.points);

  const settings = await getSettings();
  const gifFileId = await uploadTrackGif(buffer);
  const steps = buffer.events.map((event) => ({
    type: event.type,
    offset_ms: buffer.startedAt ? Math.max(event.at - buffer.startedAt, 0) : 0,
    data: event.data,
    attachment_id: null,
  }));

  if (buffer.events.length > 0) {
    await submitRecordingReport({
      payload: {
        type: "recording",
        title: `Recording ${new Date().toLocaleString()} — ${buffer.pageTitle ?? "page"}`,
        description: null,
        page_url: buffer.pageUrl,
        page_title: buffer.pageTitle,
        environment: buffer.environment ?? {},
        steps,
        attachment_ids: gifFileId ? [gifFileId] : [],
        mouse_track: buildMouseTrack(buffer),
      },
      settings,
    }).catch(async (error) => {
      // surface submit failure to the content script
      buffer.recording = false;
      throw error;
    });
  }

  buffer.recording = false;
  buffer.startedAt = null;
  buffer.events = [];
  buffer.screenshots = new Map();
  buffer.moveTrack = [];
  buffer.video = [];
  return recorderStateOf(buffer);
}

/**
 * Uploads the recorded screen as a silent WebM video; when video encoding
 * isn't possible (Firefox, WebCodecs missing) falls back to the cursor GIF
 * and then to per-event screenshots. Returns the uploaded file id.
 */
async function uploadTrackGif(buffer: RecorderBuffer): Promise<string | null> {
  if (buffer.video.length) {
    try {
      const video = await encodeVideoOffscreen(buffer.video, buffer.startedAt ?? Date.now());
      if (video) return await uploadRecordingMedia(video, "recording.webm");
      debugLog("screen video failed: offscreen API unavailable");
    } catch (error) {
      debugLog(`screen video failed: ${error instanceof Error ? error.message : String(error)}`);
    }
    try {
      // video encoding failed but the screencast frames are here — cursor GIF
      const viewport = (buffer.environment?.viewport as { w?: number; h?: number } | undefined) ?? null;
      const track = buildMouseTrack(buffer)?.points ?? [];
      const blob = await buildCursorGif(buffer.video, {
        size: 512,
        viewport: viewport?.w && viewport?.h ? { w: viewport.w, h: viewport.h } : { w: 1280, h: 720 },
        startedAt: buffer.startedAt ?? Date.now(),
        track,
      });
      if (blob) return await uploadRecordingMedia(blob, "recording.gif");
      debugLog("cursor gif failed: no frames encoded");
    } catch (error) {
      debugLog(`cursor gif failed: ${error instanceof Error ? error.message : String(error)}`);
    }
  }

  const indices = [...buffer.screenshots.keys()].sort((a, b) => a - b);
  const frames = indices
    .filter((index) => buffer.screenshots.has(index))
    .map((index, position, list) => {
      const at = buffer.events[index]?.at ?? Date.now();
      const nextAt = position + 1 < list.length ? buffer.events[list[position + 1]]?.at : undefined;
      // pace the GIF like the track; degenerate gaps get sane bounds
      const delayMs = nextAt != null ? Math.min(Math.max(nextAt - at, 400), 2000) : 1500;
      return { dataUrl: buffer.screenshots.get(index)!, delayMs };
    });
  if (!frames.length) return null;
  try {
    const blob = await buildGif(frames);
    if (!blob) return null;
    return await uploadRecordingMedia(blob, "recording.gif");
  } catch (error) {
    debugLog(`track gif failed: ${error instanceof Error ? error.message : String(error)}`);
    return null;
  }
}

async function uploadRecordingMedia(blob: Blob, filename: string): Promise<string | null> {
  const client = await getHttpClient();
  const file = new File([blob], filename, { type: blob.type });
  const uploaded = await client.upload<{ file_id: string }>("/api/uploads", file);
  debugLog(`recording media uploaded: ${filename} (${Math.round(blob.size / 1024)} KB)`);
  return uploaded.file_id;
}

// ---------- offscreen video encoding (WebCodecs lives only there) ----------

/** Minimal shape of the chrome.offscreen API (absent in Firefox). */
interface OffscreenApiLike {
  createDocument(params: { url: string; reasons: string[]; justification: string }): Promise<void>;
  closeDocument(): Promise<void>;
}

/** base64 → bytes (wire format to the offscreen document) */
function bytesToBase64(bytes: Uint8Array): string {
  let binary = "";
  const chunk = 0x8000;
  for (let i = 0; i < bytes.length; i += chunk) {
    binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
  }
  return btoa(binary);
}

async function ensureOffscreenDocument(): Promise<OffscreenApiLike | null> {
  const api = (browser as unknown as { offscreen?: OffscreenApiLike }).offscreen;
  if (!api) return null; // Firefox / old Chrome — GIF fallback
  try {
    await api.createDocument({
      url: "src/offscreen/offscreen.html",
      reasons: ["BLOBS"],
      justification: "Encode recorded screencast frames into a WebM video",
    });
  } catch (error) {
    // already open (previous encode crashed) — reuse it
    if (!String(error).includes("already exists")) throw error;
  }
  return api;
}

/** per-message payload cap for the frame transfer (~8 MB of base64) */
const VIDEO_BATCH_BYTES = 6 * 1024 * 1024;
const VIDEO_ENCODE_TIMEOUT_MS = 120_000;

/**
 * Sends the recorded frames to the offscreen document and gets the encoded
 * WebM back. Runs over a dedicated runtime port: the service worker's own
 * async onMessage listener would win the response race for plain messages.
 */
async function encodeVideoOffscreen(frames: VideoFrame[], startedAt: number): Promise<Blob | null> {
  const api = await ensureOffscreenDocument();
  if (!api) return null;
  try {
    return await new Promise<Blob>((resolve, reject) => {
      let settled = false;
      const port = browser.runtime.connect({ name: "video-encode" });
      const finish = (fn: () => void) => {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        port.disconnect();
        fn();
      };
      const timer = setTimeout(() => finish(() => reject(new Error("video encoding timed out"))), VIDEO_ENCODE_TIMEOUT_MS);
      port.onMessage.addListener((raw: unknown) => {
        const msg = raw as { type: string; ok?: boolean; b64?: string; error?: string };
        if (msg.type !== "encode_video_result") return;
        if (msg.ok && msg.b64) {
          const bytes = base64ToBytes(msg.b64);
          finish(() => resolve(new Blob([bytes.buffer as ArrayBuffer], { type: "video/webm" })));
        } else {
          finish(() => reject(new Error(msg.error ?? "video encoding failed")));
        }
      });
      port.onDisconnect.addListener(() => finish(() => reject(new Error("offscreen encoder disconnected"))));

      port.postMessage({ type: "encode_video_start" });
      let batch: { at: number; b64: string; w: number; h: number }[] = [];
      let batchBytes = 0;
      for (const frame of frames) {
        batch.push({ at: frame.at, b64: bytesToBase64(frame.jpeg), w: frame.w, h: frame.h });
        batchBytes += frame.jpeg.length;
        if (batchBytes >= VIDEO_BATCH_BYTES) {
          port.postMessage({ type: "encode_video_frames", frames: batch });
          batch = [];
          batchBytes = 0;
        }
      }
      if (batch.length) port.postMessage({ type: "encode_video_frames", frames: batch });
      port.postMessage({ type: "encode_video_run" });
    });
  } finally {
    await api.closeDocument().catch(() => {});
  }
}

// ---------- content-script injection ----------

async function ensureContentScript(tabId: number): Promise<void> {
  try {
    await browser.tabs.sendMessage(tabId, { type: "ping" });
    return;
  } catch {
    // not injected yet
  }
  await browser.scripting.executeScript({
    target: { tabId },
    files: ["content.js"],
  });
}

async function startPicker(tabId: number): Promise<void> {
  await ensureContentScript(tabId);
  OVERLAY_ACTIVE.add(tabId);
  await browser.tabs.sendMessage(tabId, { type: "start_picker" }).catch(async (error) => {
    // the page may need a reload for the fresh script; retry once
    await browser.scripting.executeScript({ target: { tabId }, files: ["content.js"] });
    await browser.tabs.sendMessage(tabId, { type: "start_picker" }).catch(() => {
      throw error;
    });
  });
}

async function startRecorder(tabId: number): Promise<RecorderState> {
  await ensureContentScript(tabId);
  const state = await recorderStart(tabId);
  await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: true }).catch(() => {});
  return state;
}

async function toggleRecorder(tabId: number): Promise<RecorderState> {
  const buffer = recorder(tabId);
  if (buffer.recording) {
    const state = await recorderStop(tabId);
    await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: false }).catch(() => {});
    return state;
  }
  return startRecorder(tabId);
}

// ---------- replay (extension users can reproduce a recorded track) ----------

/** Minimal shape of the chrome.debugger API (absent in Firefox). */
interface DebuggerApiLike {
  attach(target: { tabId: number }, version: string): Promise<void>;
  detach(target: { tabId: number }): Promise<void>;
  sendCommand(target: { tabId: number }, method: string, params?: Record<string, unknown>): Promise<unknown>;
  onEvent: {
    addListener(callback: (source: { tabId: number }, method: string, params?: Record<string, unknown>) => void): void;
  };
  onDetach: {
    addListener(callback: (source: { tabId: number }) => void): void;
  };
}

function debuggerApi(): DebuggerApiLike | null {
  return (browser as unknown as { debugger?: DebuggerApiLike }).debugger ?? null;
}

// tabs with the debugger attached for CSS :hover replay during a running replay
const hoverDebuggerTabs = new Set<number>();

/**
 * Attaches the Chrome debugger to the replay tab so the virtual cursor's
 * positions can be replayed as *trusted* input (Input.dispatchMouseEvent) —
 * only trusted input applies CSS :hover, synthetic DOM events can't.
 * Chrome shows its "started debugging" infobar; the user can turn this off
 * via the hoverReplay setting. Best effort: a busy debugger (DevTools open)
 * just disables CSS hover for the replay.
 */
async function attachHoverDebugger(tabId: number): Promise<void> {
  const settings = await getSettings();
  if (!settings.hoverReplay) return;
  const api = debuggerApi();
  if (!api) return; // Firefox has no debugger API — JS-level hover still works
  try {
    await api.attach({ tabId }, "1.3");
    hoverDebuggerTabs.add(tabId);
    debugLog("hover debugger attached");
  } catch (error) {
    debugLog(`hover debugger attach failed: ${error instanceof Error ? error.message : String(error)}`);
  }
}

// ---------- screen video (CDP screencast while recording) ----------

/** tabs with a recording screencast attached */
const screencastTabs = new Set<number>();

/** ~15 fps of 512px squares; bounded memory, decimated when overflowing */
const VIDEO_MIN_INTERVAL_MS = 66;
const MAX_VIDEO_FRAMES = 600;
/** screencast JPEGs are downscaled by CDP; the crop needs some headroom */
const VIDEO_MAX_DIMENSION = 800;

/**
 * Starts a CDP screencast on the tab so the recording gets continuous frames
 * (captureVisibleTab is quota-limited to ~2/sec — nowhere near video). Chrome
 * shows its "started debugging" infobar; the user can turn this off via the
 * videoCapture setting. Best effort: a busy debugger just disables the video.
 */
async function startScreenVideo(tabId: number): Promise<boolean> {
  const settings = await getSettings();
  if (!settings.videoCapture) return false;
  const api = debuggerApi();
  if (!api) return false; // Firefox: per-event screenshots still feed the GIF
  try {
    await api.attach({ tabId }, "1.3");
    await api.sendCommand({ tabId }, "Page.startScreencast", {
      format: "jpeg",
      quality: 55,
      maxWidth: VIDEO_MAX_DIMENSION,
      maxHeight: VIDEO_MAX_DIMENSION,
      everyNthFrame: 1,
    });
    screencastTabs.add(tabId);
    debugLog("recording screencast started");
    return true;
  } catch (error) {
    debugLog(`recording screencast failed: ${error instanceof Error ? error.message : String(error)}`);
    return false;
  }
}

async function stopScreenVideo(tabId: number): Promise<void> {
  if (!screencastTabs.delete(tabId)) return;
  const api = debuggerApi();
  await api?.sendCommand({ tabId }, "Page.stopScreencast").catch(() => {});
  await api?.detach({ tabId }).catch(() => {});
}

/** base64 (screencast wire format) → bytes, so frames take half the memory */
function base64ToBytes(base64: string): Uint8Array {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes;
}

/** Global debugger event listener: screencast frames land in the tab's buffer. */
function onDebuggerEvent(source: { tabId: number }, method: string, params?: Record<string, unknown>): void {
  if (method !== "Page.screencastFrame") return;
  const api = debuggerApi();
  const sessionId = (params?.sessionId as number | undefined) ?? null;
  // every frame must be acked or Chrome throttles the screencast to a crawl
  if (api && sessionId != null) void api.sendCommand({ tabId: source.tabId }, "Page.screencastFrameAck", { sessionId }).catch(() => {});
  const buffer = recorders.get(source.tabId);
  if (!buffer?.recording || !buffer.videoActive) return;
  const data = params?.data as string | undefined;
  const meta = params?.metadata as { deviceWidth?: number; deviceHeight?: number } | undefined;
  if (!data || !meta?.deviceWidth || !meta.deviceHeight) return;
  const now = Date.now();
  if (now - buffer.lastVideoAt < VIDEO_MIN_INTERVAL_MS) return; // hold ~15 fps
  buffer.lastVideoAt = now;
  buffer.video.push({ at: now, jpeg: base64ToBytes(data), w: meta.deviceWidth, h: meta.deviceHeight });
  // decimate in place instead of growing without bound
  while (buffer.video.length > MAX_VIDEO_FRAMES) buffer.video = buffer.video.filter((_, i) => i % 2 === 0);
}

/** The debugger left (DevTools opened, user clicked "cancel") — no more frames. */
function onDebuggerDetach(source: { tabId: number }): void {
  screencastTabs.delete(source.tabId);
  const buffer = recorders.get(source.tabId);
  if (buffer?.videoActive) {
    buffer.videoActive = false;
    debugLog(`screencast detached from tab ${source.tabId}`);
  }
}

const debugApi = debuggerApi();
debugApi?.onEvent.addListener(onDebuggerEvent);
debugApi?.onDetach.addListener(onDebuggerDetach);

async function detachHoverDebugger(tabId: number): Promise<void> {
  if (!hoverDebuggerTabs.delete(tabId)) return;
  await debuggerApi()?.detach({ tabId }).catch(() => {});
}

function dispatchHoverMove(tabId: number, x: number, y: number): void {
  void debuggerApi()
    ?.sendCommand({ tabId }, "Input.dispatchMouseEvent", {
      type: "mouseMoved",
      x: Math.round(x),
      y: Math.round(y),
      button: "none",
      buttons: 0,
      pointerType: "mouse",
    })
    .catch(() => {});
}

function waitTabComplete(tabId: number, timeoutMs = 20000): Promise<boolean> {
  return new Promise((resolve) => {
    const finish = (completed: boolean) => {
      clearTimeout(timer);
      browser.tabs.onUpdated.removeListener(listener);
      browser.tabs.onRemoved.removeListener(removed);
      resolve(completed);
    };
    const timer = setTimeout(() => finish(false), timeoutMs);
    const listener = (id: number, changeInfo: { status?: string }) => {
      if (id === tabId && changeInfo.status === "complete") finish(true);
    };
    const removed = (id: number) => {
      if (id === tabId) finish(false);
    };
    browser.tabs.onUpdated.addListener(listener);
    browser.tabs.onRemoved.addListener(removed);
  });
}

interface ReplayFailure {
  index: number;
  type: string;
  reason: string;
}

export interface ReplayResult {
  played: number;
  total: number;
  failures: ReplayFailure[];
}

/**
 * Replays a recorded track in a fresh tab: opens the recorded page, then
 * performs the steps with their recorded pacing (gaps capped so stale
 * timings can't stall the replay) while a virtual cursor walks the recorded
 * mouse path. Page interactions are executed by the content script of the
 * replayed tab. Fails loudly when the page can't be loaded or the tab dies,
 * and collects per-step failures (e.g. elements that no longer exist).
 */
async function replayReport(token: string): Promise<ReplayResult> {
  const client = await getHttpClient();
  const report = await client.get<ReportDetail>(`/api/reports/${token}`);
  if (report.type !== "recording") throw new Error("Only recordings can be replayed");
  if (!report.page_url) throw new Error("Recording has no start page URL");

  const tab = await browser.tabs.create({ url: report.page_url, active: true });
  const tabId = tab.id;
  if (tabId == null) throw new Error("Could not open replay tab");
  if (!(await waitTabComplete(tabId))) {
    await browser.tabs.remove(tabId).catch(() => {});
    throw new Error(`Page did not load within 20 s: ${report.page_url}`);
  }
  // the replay tab is fresh — the content script may not be there yet
  await ensureContentScript(tabId);
  // a virtual cursor walks the recorded mouse path while the steps play
  if (report.mouse_track?.points?.length) {
    await browser.tabs.sendMessage(tabId, { type: "replay_start", track: report.mouse_track }).catch(() => {});
  }
  // trusted-input hover replay (CSS :hover) — must detach in every exit path
  await attachHoverDebugger(tabId);
  try {
    return await runReplaySteps(tabId, report);
  } finally {
    await detachHoverDebugger(tabId);
  }
}

/** Drives the recorded steps inside the replay tab; cursor already running. */
async function runReplaySteps(tabId: number, report: ReportDetail): Promise<ReplayResult> {

  const steps = report.steps ?? [];
  const startedAt = Date.now();
  const failures: ReplayFailure[] = [];
  let played = 0;
  for (const step of steps) {
    const stepIndex = step.step_index ?? played;
    // reproduce the recorded pacing, but cap each gap so stale timings don't stall
    const wait = Math.min(Math.max(step.offset_ms - (Date.now() - startedAt), 0), 2000);
    if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
    // the tab may have been closed mid-replay (by the user or the page itself)
    const alive = await browser.tabs.get(tabId).then(
      () => true,
      () => false
    );
    if (!alive) throw new Error("Replay tab was closed before the track finished");
    if (step.type === "url_change" && typeof step.data?.to_url === "string") {
      await browser.tabs.update(tabId, { url: step.data.to_url });
      if (!(await waitTabComplete(tabId))) {
        throw new Error(`Page did not load within 20 s: ${step.data.to_url}`);
      }
      // the navigation destroyed the page's content script and cursor —
      // reinstall both, resuming the cursor path from the current step
      await ensureContentScript(tabId);
      if (report.mouse_track?.points?.length) {
        await browser.tabs
          .sendMessage(tabId, { type: "replay_start", track: report.mouse_track, from_t: step.offset_ms })
          .catch(() => {});
      }
      played++;
    } else if (step.type === "click" || step.type === "input") {
      const response = (await browser.tabs.sendMessage(tabId, { type: "replay_step", data: step.data }).catch(() => null)) as
        | { ok: boolean }
        | null;
      if (response?.ok) {
        played++;
      } else {
        failures.push({ index: stepIndex, type: step.type, reason: "Element not found on the current page" });
      }
    } else {
      // note/screenshot/console steps have no page effect — still count as played
      played++;
    }
    // keep the cursor clock aligned with the (gap-capped) step clock so the
    // virtual mouse doesn't drift behind the actions as the replay proceeds
    await browser.tabs.sendMessage(tabId, { type: "replay_sync", recorded_ms: step.offset_ms }).catch(() => {});
  }

  await new Promise((resolve) => setTimeout(resolve, 500));
  await browser.tabs.sendMessage(tabId, { type: "replay_stop" }).catch(() => {});
  const summary = failures.length
    ? `Replay finished: ${played}/${steps.length} steps (${failures.length} failed)`
    : `Replay finished: ${played}/${steps.length} steps`;
  await browser.tabs.sendMessage(tabId, { type: "show_flash", text: summary }).catch(() => {});
  return { played, total: steps.length, failures };
}

/** Latest report of the default project + its project share token (for the panel link). */
async function latestReport(): Promise<{
  report: { title: string; type: string; status: string; created_at: string; share_token: string } | null;
  project_token: string | null;
} | null> {
  const settings = await getSettings();
  if (!settings.token || !settings.defaultProjectId) return null;
  const client = await getHttpClient();
  const projects = await client.get<{ id: string; share_token: string }[]>("/api/projects");
  const project = projects.find((p) => p.id === settings.defaultProjectId);
  const list = await client.get<{ items: { title: string; type: string; status: string; created_at: string; share_token: string }[] }>(
    `/api/projects/${settings.defaultProjectId}/reports?limit=1`
  );
  return { report: list.items[0] ?? null, project_token: project?.share_token ?? null };
}

// ---------- wiring ----------

browser.action.onClicked.addListener(async (tab) => {
  if (tab.id == null) return;
  try {
    // first run: the toolbar click takes you to settings instead of the picker
    const settings = await getSettings();
    if (!settings.token || !settings.defaultProjectId) {
      await browser.runtime.openOptionsPage();
      return;
    }
    await startPicker(tab.id);
  } catch (error) {
    console.error("[ltt] failed to start picker:", error);
  }
});

browser.commands?.onCommand.addListener(async (command) => {
  const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
  if (tab?.id == null) return;
  if (command === "start-picker") {
    await startPicker(tab.id).catch(console.error);
  } else if (command === "toggle-recorder") {
    const buffer = recorder(tab.id);
    if (buffer.recording) {
      await recorderStop(tab.id).catch(console.error);
      await browser.tabs.sendMessage(tab.id, { type: "recorder_state", recording: false }).catch(() => {});
    } else {
      await startRecorder(tab.id).catch(console.error);
    }
  }
});

browser.tabs.onRemoved.addListener((tabId) => {
  OVERLAY_ACTIVE.delete(tabId);
  recorders.delete(tabId);
  void stopScreenVideo(tabId);
});

// debug/test hook: lets the e2e harness (and DevTools) trigger the action
// and recorder-toggle handlers the way the toolbar button and shortcuts do
Object.assign(self as unknown as Record<string, unknown>, {
  __lttDebug: () => DEBUG_LOG,
  __lttTriggerAction: async () => {
    const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
    if (tab?.id == null) return;
    await startPicker(tab.id).catch(console.error);
  },
  __lttToggleRecorder: async () => {
    const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
    if (tab?.id == null) return;
    const buffer = recorder(tab.id);
    if (buffer.recording) {
      await recorderStop(tab.id).catch(console.error);
      await browser.tabs.sendMessage(tab.id, { type: "recorder_state", recording: false }).catch(() => {});
    } else {
      await startRecorder(tab.id).catch(console.error);
    }
  },
});

// keep the recorder's page metadata fresh on SPA navigations; the report's
// page_url stays the URL where recording *started* (replay starts there)
browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
  const buffer = recorders.get(tabId);
  if (!buffer?.recording) return;
  if (changeInfo.url && buffer.lastUrl !== changeInfo.url && tab.url) {
    if (!buffer.pageUrl) buffer.pageUrl = tab.url;
    const eventIndex = buffer.events.push({
      type: "url_change",
      at: Date.now(),
      data: { from_url: buffer.lastUrl, to_url: tab.url, trigger: "tab_updated" },
    }) - 1;
    buffer.lastUrl = tab.url;
    if (!buffer.videoActive) void captureForEvent(tabId, eventIndex);
  }
  // a full navigation destroyed the page's capture listeners — reinstall them
  // (and the recorder bar) once the new document is ready
  if (changeInfo.status === "complete") {
    await ensureContentScript(tabId);
    await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: true }).catch(() => {});
  }
});

browser.runtime.onMessage.addListener(async (message: unknown, sender: Runtime.MessageSender) => {
  const msg = message as { type: string; [key: string]: unknown };
  const tabId = sender.tab?.id;

  switch (msg.type) {
    case "ping":
      return { ok: true, data: "pong" };

    // popup actions: the popup has no tab context, resolve the active tab here
    case "start_picker": {
      const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
      if (tab?.id == null) return { ok: false, error: "No active tab" };
      await startPicker(tab.id).catch((error) => console.error("[ltt] failed to start picker:", error));
      return { ok: true };
    }

    case "toggle_recorder": {
      const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
      if (tab?.id == null) return { ok: false, error: "No active tab" };
      try {
        const state = await toggleRecorder(tab.id);
        return { ok: true, data: state };
      } catch (error) {
        return { ok: false, error: error instanceof Error ? error.message : String(error) };
      }
    }

    case "latest_report": {
      try {
        return { ok: true, data: await latestReport() };
      } catch (error) {
        return { ok: false, error: error instanceof Error ? error.message : String(error) };
      }
    }

    case "delete_report": {
      try {
        const client = await getHttpClient();
        await client.del(`/api/reports/${msg.token as string}`);
        return { ok: true };
      } catch (error) {
        return { ok: false, error: error instanceof Error ? error.message : String(error) };
      }
    }

    case "open_panel": {
      const settings = await getSettings();
      await browser.tabs.create({ url: panelBase(settings) });
      return { ok: true };
    }

    case "replay_report": {
      try {
        return { ok: true, data: await replayReport(msg.token as string) };
      } catch (error) {
        return { ok: false, error: error instanceof Error ? error.message : String(error) };
      }
    }

    case "replay_hover_move": {
      // virtual cursor position mirrored for trusted-input CSS :hover
      if (tabId != null && hoverDebuggerTabs.has(tabId)) {
        dispatchHoverMove(tabId, Number(msg.x), Number(msg.y));
      }
      return { ok: true };
    }

    case "capture": {
      debugLog("capture requested");
      try {
        const dataUrl = await captureVisibleTab(tabId!);
        debugLog(`capture ok (${dataUrl.length} bytes)`);
        return { ok: true, data: { dataUrl } };
      } catch (error) {
        debugLog(`capture failed: ${String(error)}`);
        return { ok: false, error: String(error) };
      }
    }

    case "submit_note": {
      debugLog("submit_note received");
      try {
        const result = await submitNoteReport(msg.payload as Parameters<typeof submitNoteReport>[0]);
        debugLog(`submit_note ok: ${JSON.stringify(result)}`);
        return { ok: true, data: result };
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        debugLog(`submit_note failed: ${message}`);
        return { ok: false, error: message };
      }
    }

    case "recorder_event": {
      const buffer = recorder(tabId!);
      if (!buffer.recording) return { ok: true, data: { recording: false } };
      ensureRecordingFreshness(buffer);
      const { event } = msg as unknown as { event: RecordEvent };
      buffer.events.push(event);
      // a frame per event feeds the fallback GIF when the screencast is off
      // (bounded to avoid runaway memory); the video path doesn't need them
      if (!buffer.videoActive && buffer.events.length <= 60) {
        await captureForEvent(tabId!, buffer.events.length - 1);
      }
      return { ok: true, data: { stepCount: buffer.events.length } };
    }

    case "recorder_console": {
      // console dump relayed from the MAIN-world tap — becomes timeline steps
      const buffer = recorder(tabId!);
      debugLog(`console event received (recording: ${buffer.recording}, tab: ${tabId})`);
      if (!buffer.recording) return { ok: true, data: { recording: false } };
      const settings = await getSettings();
      if (!settings.consoleCapture) return { ok: true, data: { recording: false } };
      if (buffer.consoleCount >= 200) return { ok: true };
      buffer.consoleCount++;
      buffer.events.push({
        type: "console",
        at: typeof msg.at === "number" ? msg.at : Date.now(),
        data: {
          level: msg.level === "warning" ? "warning" : "error",
          text: String(msg.text ?? "").slice(0, 2000),
        },
      });
      return { ok: true };
    }

    case "recorder_environment": {
      const buffer = recorder(tabId!);
      buffer.environment = msg.environment as Record<string, unknown>;
      return { ok: true };
    }

    case "recorder_move_batch": {
      const buffer = recorder(tabId!);
      if (!buffer.recording) return { ok: true, data: { recording: false } };
      const points = (msg.points ?? []) as { at: number; x: number; y: number }[];
      if (buffer.moveTrack.length + points.length <= 20000) buffer.moveTrack.push(...points);
      return { ok: true };
    }

    case "recorder_screenshot": {
      // a manual screenshot becomes its own step attached to the recording
      const buffer = recorder(tabId!);
      if (!buffer.recording) return { ok: true, data: { recording: false } };
      ensureRecordingFreshness(buffer);
      buffer.events.push({
        type: "screenshot",
        at: Date.now(),
        data: { trigger: "manual" },
      });
      await captureForEvent(tabId!, buffer.events.length - 1);
      return { ok: true, data: { stepCount: buffer.events.length } };
    }

    case "open_options": {
      await browser.runtime.openOptionsPage();
      return { ok: true };
    }

    case "list_projects": {
      try {
        const client = await getHttpClient();
        const projects = await client.get<{ id: string; name: string }[]>("/api/projects");
        return { ok: true, data: projects };
      } catch (error) {
        return { ok: false, error: error instanceof Error ? error.message : String(error) };
      }
    }

    case "recorder_stop": {
      try {
        const state = await recorderStop(tabId!);
        return { ok: true, data: state };
      } catch (error) {
        return { ok: false, error: error instanceof Error ? error.message : String(error) };
      }
    }

    case "recorder_state_request": {
      const buffer = tabId != null ? recorders.get(tabId) : undefined;
      return { ok: true, data: buffer ? recorderStateOf(buffer) : { recording: false, stepCount: 0, startedAt: null } };
    }

    case "settings_get": {
      return { ok: true, data: await getSettings() };
    }

    case "settings_save": {
      await saveSettings(msg.patch as Record<string, never>);
      return { ok: true };
    }

    case "login": {
      try {
        const user = await login(msg.email as string, msg.password as string);
        return { ok: true, data: user };
      } catch (error) {
        return { ok: false, error: error instanceof Error ? error.message : String(error) };
      }
    }

    case "logout": {
      await logout();
      return { ok: true };
    }

    default:
      return { ok: false, error: `Unknown message type: ${msg.type}` };
  }
});