// paliadin-widget.ts — runtime for the inline Paliadin floating button +
// slide-out drawer (t-paliad-161).
//
// Lifecycle:
//   1. On DOMContentLoaded, fetch /api/me. If the email matches the
//      Paliadin owner gate (the same gate the standalone /paliadin
//      route uses) AND the route is one where the widget shows, reveal
//      the trigger button.
//   2. Click trigger or press Cmd+J / Ctrl+J → open drawer + populate
//      starter prompts from paliadin-starters.ts.
//   3. Submit form → POST /api/paliadin/turn with structured context
//      from computePaliadinContext() → consume the SSE stream → render
//      assistant bubble.
//   4. Conversation history persists in localStorage per session id.
//
// Notes:
//   - Cmd+K is reserved for the global search palette (client/search.ts).
//     The widget uses Cmd+J / Ctrl+J as the keyboard trigger.
//   - The standalone /paliadin page's client (client/paliadin.ts) is
//     unchanged — this widget reuses /api/paliadin/turn but ships its
//     own UI and history bucket so the two surfaces stay independent.
//   - Visibility predicate mirrors paliadin-context.shouldSendContext()
//     so the widget never sends a turn from a route where it shouldn't
//     show.

import { initI18n, getLang, t } from "./i18n";
import { computePaliadinContext, shouldSendContext, routeNameFor } from "./paliadin-context";
import { startersFor, type Starter } from "./paliadin-starters";
import { renderResponseHTML } from "./paliadin-render";
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";

interface MeResponse {
  id: string;
  email: string;
  display_name?: string;
  global_role?: string;
}

interface HistoryEntry {
  role: "user" | "assistant";
  text: string;
  ts: string;
}

interface TurnResponse {
  turn_id: string;
  sse_url: string;
}

// Shared session key — the inline drawer and the standalone /paliadin
// page must use the same browser-session id so both surfaces show the
// same conversation. Migration on first run: if a legacy
// `paliadin:widget:session` exists but the shared `paliadin:session`
// does not, copy across so the user doesn't lose drawer state on the
// rollover.
const SESSION_KEY = "paliadin:session";
const LEGACY_WIDGET_SESSION_KEY = "paliadin:widget:session";
// History bucket — render-cache only; DB is source of truth (server
// hydrates via /api/paliadin/history on every mount). The cache is keyed
// by session id so a session reset gives a clean slate.
const HISTORY_PREFIX = "paliadin:history:";

let sessionId: string;
let history: HistoryEntry[] = [];
let drawerOpen = false;
let activeStream: EventSource | null = null;
let pending = false;
// Late-response pollers per turn_id (see paliadin-late-poll.ts).
const lateWidgetPolls = new Map<string, LatePollHandle>();

document.addEventListener("DOMContentLoaded", () => {
  const trigger = document.getElementById("paliadin-widget-trigger");
  const drawer = document.getElementById("paliadin-widget-drawer");
  if (!trigger || !drawer) return; // page didn't include the widget — skip silently
  initI18n();
  bootSession();
  void revealIfOwner();
  wireTrigger();
  wireDrawerControls();
  wireForm();
  wireKeyboardShortcut();
});

function bootSession(): void {
  let s = localStorage.getItem(SESSION_KEY);
  if (!s) {
    // One-time migration: previous widget builds wrote
    // `paliadin:widget:session` instead of the shared key. Carry over
    // the existing id so the user keeps their conversation thread.
    const legacy = localStorage.getItem(LEGACY_WIDGET_SESSION_KEY);
    s = legacy || crypto.randomUUID();
    localStorage.setItem(SESSION_KEY, s);
  }
  // Drop the legacy key now that we've migrated; harmless if it's
  // already absent.
  localStorage.removeItem(LEGACY_WIDGET_SESSION_KEY);
  sessionId = s;
  loadHistory();
}

function loadHistory(): void {
  const stored = localStorage.getItem(HISTORY_PREFIX + sessionId);
  if (!stored) {
    history = [];
    return;
  }
  try {
    const parsed = JSON.parse(stored);
    history = Array.isArray(parsed) ? parsed.slice(-30) : [];
  } catch {
    history = [];
  }
}

function saveHistory(): void {
  try {
    localStorage.setItem(HISTORY_PREFIX + sessionId, JSON.stringify(history.slice(-30)));
  } catch {
    /* localStorage quota or disabled — non-fatal */
  }
}

async function revealIfOwner(): Promise<void> {
  if (!shouldSendContext(window.location.pathname)) return; // route excluded
  let me: MeResponse;
  try {
    const r = await fetch("/api/me", { credentials: "same-origin" });
    if (!r.ok) return;
    me = await r.json();
  } catch {
    return;
  }
  // The server-side handler returns 404 for non-owners on every paliadin
  // route, so we don't need to know the owner email client-side. Probe
  // /api/paliadin/me-check (a 200/404 endpoint) — but that endpoint
  // doesn't exist; instead reuse the same reveal hook the sidebar uses,
  // which checks an `is_paliadin_owner` flag the /api/me payload includes
  // when paliadinSvc is wired and the caller matches.
  if (!isPaliadinOwner(me)) return;
  showTrigger();
  renderStarters();
  rehydrateHistory();
  // Refresh from DB in the background so cross-surface activity (a
  // turn typed on the standalone /paliadin page) shows up here without
  // a manual reload.
  void hydrateFromServer();
}

function isPaliadinOwner(me: MeResponse): boolean {
  // Server-driven flag (matches the pattern client/sidebar.ts uses to
  // reveal the /paliadin sidebar entry). Fallback to email match only
  // if the flag is absent — this keeps the widget working on a server
  // build that hasn't shipped the flag yet.
  const flag = (me as unknown as { is_paliadin_owner?: boolean }).is_paliadin_owner;
  if (typeof flag === "boolean") return flag;
  // Fallback: hardcoded owner match. Same string as
  // services.PaliadinOwnerEmail in Go — keep in sync.
  return (me.email || "").toLowerCase() === "matthias.siebels@hoganlovells.com";
}

function showTrigger(): void {
  const trigger = document.getElementById("paliadin-widget-trigger");
  if (trigger) trigger.style.display = "";
}

function wireTrigger(): void {
  const trigger = document.getElementById("paliadin-widget-trigger");
  trigger?.addEventListener("click", () => openDrawer());
}

function wireDrawerControls(): void {
  document.getElementById("paliadin-widget-close")?.addEventListener("click", () => closeDrawer());
  document.getElementById("paliadin-widget-scrim")?.addEventListener("click", () => closeDrawer());
  document.getElementById("paliadin-widget-reset")?.addEventListener("click", () => void resetSession());
  document.addEventListener("keydown", (e) => {
    if (e.key === "Escape" && drawerOpen) {
      e.preventDefault();
      closeDrawer();
    }
  });
}

function wireKeyboardShortcut(): void {
  // Cmd+J / Ctrl+J — open or close the drawer. Cmd+K is reserved for
  // global search (client/search.ts), so we use J ("Junior assistant").
  document.addEventListener("keydown", (e) => {
    const isCmdJ = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "j";
    if (!isCmdJ) return;
    const trigger = document.getElementById("paliadin-widget-trigger");
    if (!trigger || trigger.style.display === "none") return; // widget not revealed
    e.preventDefault();
    e.stopPropagation();
    if (drawerOpen) {
      closeDrawer();
    } else {
      openDrawer();
    }
  });
}

function openDrawer(): void {
  if (drawerOpen) return;
  drawerOpen = true;
  const drawer = document.getElementById("paliadin-widget-drawer");
  const scrim = document.getElementById("paliadin-widget-scrim");
  if (drawer) {
    drawer.style.display = "";
    drawer.dataset.open = "true";
    drawer.setAttribute("aria-hidden", "false");
  }
  if (scrim) {
    scrim.style.display = "";
  }
  // Force reflow so the slide-in animation runs (CSS transitions need a
  // flip from off-canvas to on-canvas).
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
  drawer?.offsetWidth;
  if (drawer) drawer.classList.add("paliadin-widget-drawer--visible");
  if (scrim) scrim.classList.add("paliadin-widget-scrim--visible");

  refreshContextChip();
  renderStarters();
  // Pull the canonical conversation from the DB on every open so a
  // turn the user typed on /paliadin (or another tab) since the last
  // open is reflected here.
  void hydrateFromServer();
  setTimeout(() => {
    document.getElementById("paliadin-widget-input")?.focus();
  }, 60);
}

function closeDrawer(): void {
  if (!drawerOpen) return;
  drawerOpen = false;
  const drawer = document.getElementById("paliadin-widget-drawer");
  const scrim = document.getElementById("paliadin-widget-scrim");
  drawer?.classList.remove("paliadin-widget-drawer--visible");
  scrim?.classList.remove("paliadin-widget-scrim--visible");
  // Wait for transition before display:none so the slide-out animates.
  setTimeout(() => {
    if (drawerOpen) return; // re-opened during transition
    if (drawer) {
      drawer.style.display = "none";
      drawer.dataset.open = "false";
      drawer.setAttribute("aria-hidden", "true");
    }
    if (scrim) scrim.style.display = "none";
  }, 220);
}

function refreshContextChip(): void {
  const chip = document.getElementById("paliadin-widget-context-chip");
  const value = document.getElementById("paliadin-widget-context-value");
  if (!chip || !value) return;
  const ctx = computePaliadinContext();
  if (!ctx) {
    chip.style.display = "none";
    return;
  }
  const labelParts: string[] = [];
  if (ctx.primary_entity_type === "project") {
    labelParts.push(getLang() === "en" ? "Project" : "Akte");
  } else if (ctx.primary_entity_type === "deadline") {
    labelParts.push(getLang() === "en" ? "Deadline" : "Frist");
  } else if (ctx.primary_entity_type === "appointment") {
    labelParts.push(getLang() === "en" ? "Appointment" : "Termin");
  }
  labelParts.push(humanRouteName(ctx.route_name));
  value.textContent = labelParts.join(" · ");
  chip.style.display = "";
}

function humanRouteName(route: string): string {
  // Prefer i18n key if present; fall back to a tidied form of the
  // route key itself.
  const key = "paliadin.widget.route." + route;
  const translated = t(key);
  if (translated && translated !== key) return translated;
  return route;
}

function renderStarters(): void {
  const host = document.getElementById("paliadin-widget-starters");
  if (!host) return;
  const route = routeNameFor(window.location.pathname);
  const lang = getLang();
  const list = startersFor(route);
  host.innerHTML = "";
  list.forEach((s) => {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = "paliadin-widget-starter";
    btn.textContent = lang === "en" ? s.label_en : s.label_de;
    btn.addEventListener("click", () => onStarterClick(s));
    host.appendChild(btn);
  });
}

function onStarterClick(s: Starter): void {
  const lang = getLang();
  const promptText = lang === "en" ? s.prompt_en : s.prompt_de;
  const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
  if (!input) return;
  if (!promptText) {
    input.value = "";
    input.focus();
    return;
  }
  // Prompts that end with ": " are intentional partial seeds — leave
  // the textarea so the user finishes the sentence.
  if (promptText.endsWith(": ")) {
    input.value = promptText;
    input.focus();
    input.setSelectionRange(promptText.length, promptText.length);
    return;
  }
  input.value = promptText;
  // Auto-send for fully-formed prompts.
  void sendTurn();
}

function wireForm(): void {
  const form = document.getElementById("paliadin-widget-form") as HTMLFormElement | null;
  const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
  if (!form || !input) return;
  form.addEventListener("submit", (e) => {
    e.preventDefault();
    void sendTurn();
  });
  input.addEventListener("keydown", (e) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      void sendTurn();
    }
  });
}

async function sendTurn(): Promise<void> {
  if (pending) return;
  const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
  if (!input) return;
  const text = input.value.trim();
  if (!text) return;
  input.value = "";

  hideEmpty();
  appendBubble("user", text);
  history.push({ role: "user", text, ts: new Date().toISOString() });
  saveHistory();

  pending = true;
  setSendDisabled(true);
  const placeholder = appendBubble("assistant", "Paliadin denkt nach …");
  placeholder.dataset.streaming = "true";

  let turnRes: TurnResponse;
  try {
    const ctx = computePaliadinContext();
    const r = await fetch("/api/paliadin/turn", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "same-origin",
      body: JSON.stringify({
        user_message: text,
        session_id: sessionId,
        page_origin: window.location.pathname + window.location.search,
        context: ctx ?? undefined,
      }),
    });
    if (!r.ok) throw new Error("HTTP " + r.status);
    turnRes = await r.json();
  } catch {
    setBubbleText(placeholder, t("paliadin.error.upstream"));
    placeholder.classList.add("paliadin-widget-bubble--error");
    placeholder.dataset.streaming = "false";
    pending = false;
    setSendDisabled(false);
    return;
  }

  const es = new EventSource(turnRes.sse_url);
  activeStream = es;

  startWidgetThinking(placeholder);

  let fullText = "";
  es.addEventListener("thinking", (ev) => {
    let elapsed = 0;
    try {
      const data = JSON.parse((ev as MessageEvent).data || "{}");
      if (typeof data.elapsed_seconds === "number") elapsed = data.elapsed_seconds;
    } catch {
      /* ignore */
    }
    updateWidgetThinking(placeholder, elapsed);
  });
  es.addEventListener("content", (ev) => {
    try {
      const data = JSON.parse((ev as MessageEvent).data);
      if (typeof data.delta === "string" && data.delta) {
        // Streamed delta (aichat backend) — append.
        stopWidgetThinking(placeholder);
        fullText += data.delta;
        setBubbleText(placeholder, fullText);
        return;
      }
      // Legacy one-shot full-text payload.
      fullText = String(data.text || "");
      stopWidgetThinking(placeholder);
      setBubbleText(placeholder, fullText);
    } catch {
      /* ignore parse error */
    }
  });
  es.addEventListener("end", () => {
    placeholder.dataset.streaming = "false";
    stopWidgetThinking(placeholder);
    history.push({ role: "assistant", text: fullText || "", ts: new Date().toISOString() });
    saveHistory();
    cleanupStream();
  });
  es.addEventListener("error", () => {
    stopWidgetThinking(placeholder);
    const errText = t("paliadin.error.connection_lost");
    setBubbleText(placeholder, errText + " " + t("paliadin.late.checking"));
    placeholder.classList.add("paliadin-widget-bubble--error");
    placeholder.classList.add("paliadin-widget-bubble--late-pending");
    placeholder.dataset.streaming = "false";
    placeholder.dataset.turnId = turnRes.turn_id;
    startWidgetLatePoll(turnRes.turn_id, placeholder);
    cleanupStream();
  });
  es.addEventListener("ping", () => {
    /* heartbeat */
  });
}

function startWidgetThinking(bubble: HTMLElement): void {
  if (bubble.querySelector(".paliadin-widget-thinking")) return;
  // Clear the static placeholder text — the live pulse + counter is
  // the canonical "denkt nach" signal.
  const textNode = bubble.querySelector(".paliadin-widget-bubble-text");
  if (textNode) textNode.textContent = "";
  const node = document.createElement("div");
  node.className = "paliadin-widget-thinking";
  node.innerHTML = `
    <span class="paliadin-widget-thinking-dot" aria-hidden="true"></span>
    <span class="paliadin-widget-thinking-label"></span>
    <span class="paliadin-widget-thinking-elapsed"></span>
  `;
  const label = node.querySelector(".paliadin-widget-thinking-label")!;
  label.textContent = t("paliadin.thinking");
  bubble.appendChild(node);
  updateWidgetThinking(bubble, 0);
}

function updateWidgetThinking(bubble: HTMLElement, elapsedSeconds: number): void {
  const node = bubble.querySelector(".paliadin-widget-thinking") as HTMLElement | null;
  if (!node) return;
  const elapsed = node.querySelector(".paliadin-widget-thinking-elapsed");
  if (elapsed) {
    const s = elapsedSeconds < 0 ? 0 : Math.round(elapsedSeconds);
    elapsed.textContent = t("paliadin.thinking.seconds").replace("{seconds}", String(s));
  }
}

function stopWidgetThinking(bubble: HTMLElement): void {
  bubble.querySelector(".paliadin-widget-thinking")?.remove();
}

function cleanupStream(): void {
  activeStream?.close();
  activeStream = null;
  pending = false;
  setSendDisabled(false);
}

function startWidgetLatePoll(turnId: string, bubble: HTMLElement): void {
  lateWidgetPolls.get(turnId)?.cancel();
  const handle = pollForLateResponse({
    turnId,
    onLateResponse: (turn) => {
      lateWidgetPolls.delete(turnId);
      applyWidgetLateResponse(bubble, turn);
    },
    onLost: () => {
      lateWidgetPolls.delete(turnId);
      applyWidgetLost(bubble);
    },
    onGiveUp: () => {
      lateWidgetPolls.delete(turnId);
      applyWidgetLost(bubble);
    },
  });
  lateWidgetPolls.set(turnId, handle);
}

function applyWidgetLost(bubble: HTMLElement): void {
  bubble.classList.remove("paliadin-widget-bubble--late-pending");
  bubble.classList.add("paliadin-widget-bubble--lost");
  setBubbleText(bubble, t("paliadin.late.lost"));
}

function applyWidgetLateResponse(bubble: HTMLElement, turn: LateTurn): void {
  if (!turn.response) return;
  bubble.classList.remove(
    "paliadin-widget-bubble--error",
    "paliadin-widget-bubble--late-pending",
  );
  bubble.classList.add("paliadin-widget-bubble--late");
  setBubbleText(bubble, turn.response);
  // Append a small "(verspätet)" tag so the late arrival is visible.
  const tag = document.createElement("span");
  tag.className = "paliadin-widget-bubble-late-tag";
  tag.textContent = " · " + t("paliadin.late.marker");
  bubble.appendChild(tag);
  history.push({
    role: "assistant",
    text: turn.response,
    ts: new Date().toISOString(),
  });
  saveHistory();
}

function setSendDisabled(disabled: boolean): void {
  const btn = document.getElementById("paliadin-widget-send-btn") as HTMLButtonElement | null;
  if (btn) btn.disabled = disabled;
  const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
  if (input) input.disabled = disabled;
}

function hideEmpty(): void {
  const empty = document.getElementById("paliadin-widget-empty");
  if (empty) empty.style.display = "none";
}

function appendBubble(role: "user" | "assistant", text: string): HTMLElement {
  const messages = document.getElementById("paliadin-widget-messages");
  const wrap = document.createElement("div");
  wrap.className = `paliadin-widget-bubble paliadin-widget-bubble--${role}`;
  const body = document.createElement("div");
  body.className = "paliadin-widget-bubble-text";
  // Assistant bubbles get the same markdown + chip pipeline as the
  // standalone /paliadin page (client/paliadin-render.ts). User bubbles
  // stay plain text — no need to interpret the user's typed markup.
  if (role === "assistant") {
    body.innerHTML = renderResponseHTML(text);
  } else {
    body.textContent = text;
  }
  wrap.appendChild(body);
  messages?.appendChild(wrap);
  if (messages) messages.scrollTop = messages.scrollHeight;
  return wrap;
}

function setBubbleText(bubble: HTMLElement, text: string): void {
  const body = bubble.querySelector(".paliadin-widget-bubble-text");
  if (body) {
    const isAssistant = bubble.classList.contains("paliadin-widget-bubble--assistant");
    if (isAssistant) {
      (body as HTMLElement).innerHTML = renderResponseHTML(text);
    } else {
      body.textContent = text;
    }
  }
  const messages = document.getElementById("paliadin-widget-messages");
  if (messages) messages.scrollTop = messages.scrollHeight;
}

function rehydrateHistory(): void {
  if (!history.length) return;
  hideEmpty();
  history.forEach((h) => appendBubble(h.role, h.text));
}

// PaliadinTurnRow mirrors the JSON shape /api/paliadin/history returns
// (services.PaliadinTurn). Fields we don't render yet (used_tools etc.)
// are typed as unknown to keep the contract loose.
interface PaliadinTurnRow {
  turn_id: string;
  session_id: string;
  started_at: string;
  user_message: string;
  response?: string | null;
  error_code?: string | null;
}

// Hydrate from the DB on every mount. Crash-resistant: a typed turn
// always lands in paliad.paliadin_turns, so even if the user closes
// the tab mid-flight or the device dies, the next mount picks it up.
//
// Reconciliation: DB > localStorage. If the DB returns rows, we trust
// them entirely and overwrite the cache. If the DB call fails or
// returns empty, we keep whatever's in localStorage (offline cushion).
async function hydrateFromServer(): Promise<void> {
  let rows: PaliadinTurnRow[] = [];
  try {
    const r = await fetch(
      "/api/paliadin/history?session=" + encodeURIComponent(sessionId) + "&limit=50",
      { credentials: "same-origin" },
    );
    if (!r.ok) return;
    const body = (await r.json()) as PaliadinTurnRow[] | null;
    rows = Array.isArray(body) ? body : [];
  } catch {
    return;
  }
  if (!rows.length) return;

  // Project DB rows into the {role, text, ts} shape the cache + render
  // path expect. Each turn becomes two entries (user prompt then
  // assistant response). Skip turns with no response (in-flight, or
  // errored without a recovery) so the bubble doesn't show
  // half-rendered placeholders on reload.
  const reconstructed: HistoryEntry[] = [];
  for (const row of rows) {
    reconstructed.push({ role: "user", text: row.user_message, ts: row.started_at });
    if (typeof row.response === "string" && row.response.length > 0) {
      reconstructed.push({ role: "assistant", text: row.response, ts: row.started_at });
    }
  }
  history = reconstructed;
  saveHistory();

  // Re-render: clear the message list + replay the canonical history.
  const messages = document.getElementById("paliadin-widget-messages");
  const empty = document.getElementById("paliadin-widget-empty");
  if (messages) {
    // Strip every prior bubble but keep the empty-state placeholder so
    // it can be hidden by hideEmpty() if we end up rendering anything.
    messages.querySelectorAll(".paliadin-widget-bubble").forEach((n) => n.remove());
    if (empty) empty.style.display = "none";
    history.forEach((h) => appendBubble(h.role, h.text));
  }
}

async function resetSession(): Promise<void> {
  if (!confirm(t("paliadin.widget.reset.confirm"))) return;
  history = [];
  saveHistory();
  const messages = document.getElementById("paliadin-widget-messages");
  if (messages) {
    messages.innerHTML = "";
    const empty = document.createElement("div");
    empty.className = "paliadin-widget-empty";
    empty.id = "paliadin-widget-empty";
    const label = document.createElement("p");
    label.className = "paliadin-widget-empty-prompt";
    label.setAttribute("data-i18n", "paliadin.widget.empty");
    label.textContent = t("paliadin.widget.empty");
    const starters = document.createElement("div");
    starters.className = "paliadin-widget-starters";
    starters.id = "paliadin-widget-starters";
    empty.appendChild(label);
    empty.appendChild(starters);
    messages.appendChild(empty);
    renderStarters();
  }
  try {
    await fetch("/api/paliadin/reset", { method: "POST", credentials: "same-origin" });
  } catch {
    /* non-fatal */
  }
}
