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 } 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 }[];
}

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: [],
    };
    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.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
  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);

  // 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 = [];
  return recorderStateOf(buffer);
}

/** Uploads the recorded track as an animated GIF; returns its file id. */
async function uploadTrackGif(buffer: RecorderBuffer): Promise<string | null> {
  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;
    const client = await getHttpClient();
    const file = new File([blob], "recording.gif", { type: "image/gif" });
    const uploaded = await client.upload<{ file_id: string }>("/api/uploads", file);
    debugLog(`track gif uploaded: ${uploaded.file_id} (${frames.length} frames)`);
    return uploaded.file_id;
  } catch (error) {
    debugLog(`track gif failed: ${error instanceof Error ? error.message : String(error)}`);
    return null;
  }
}

// ---------- 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) ----------

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(() => {});
  }

  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}`);
      }
      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" });
      }
    }
    // note/screenshot steps have no page effect — skip
  }

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

// 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
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  const buffer = recorders.get(tabId);
  if (!buffer?.recording) return;
  if (changeInfo.url) buffer.pageUrl = changeInfo.url;
  if (changeInfo.title) buffer.pageTitle = changeInfo.title;
  if (changeInfo.url && buffer.lastUrl !== changeInfo.url && 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;
    void captureForEvent(tabId, eventIndex);
  }
});

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 "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 track GIF (bounded to avoid runaway memory)
      if (buffer.events.length <= 60) {
        await captureForEvent(tabId!, buffer.events.length - 1);
      }
      return { ok: true, data: { stepCount: buffer.events.length } };
    }

    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}` };
  }
});