import "./style.css";
import {
  getDocument,
  GlobalWorkerOptions,
  type PDFPageProxy,
  type RenderTask,
} from "pdfjs-dist";
import worker from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import {
  createIcons,
  ZoomIn,
  ScanLine,
  FileText,
  Save,
  MousePointer2,
  Hand,
  PenLine,
  Highlighter,
  Square,
  Circle,
  MoveUpRight,
  Type,
  Eraser,
  Stamp,
  ImagePlus,
  Undo2,
  Redo2,
  Minus,
  Plus,
  Scan,
  Layers,
  Info,
  Trash2,
  Upload,
  Download,
  X,
  Pencil,
  LockKeyhole,
  Eye,
  EyeOff,
} from "lucide";
const icons = {
  ZoomIn,
  ScanLine,
  FileText,
  Save,
  MousePointer2,
  Hand,
  PenLine,
  Highlighter,
  Square,
  Circle,
  MoveUpRight,
  Type,
  Eraser,
  Stamp,
  ImagePlus,
  Undo2,
  Redo2,
  Minus,
  Plus,
  Scan,
  Layers,
  Info,
  Trash2,
  Upload,
  Download,
  X,
  Pencil,
  LockKeyhole,
  Eye,
  EyeOff,
};
import {
  type Annotation,
  type Layer,
  type Point,
  type Rect,
  type Viewport,
  bounds,
  clone,
  translate,
  resize,
  clientToDocument,
  inkPath,
  canEdit,
  change,
  smoothPoints,
  shapeEnd,
} from "./model";
import {
  selectionBounds,
  containsRect,
  selectEnclosed,
  moveSelection,
  styleSelection,
  deleteSelection,
} from "./selection";
import { parseXfdf, serializeXfdf, safeImage } from "./xfdf";
import { pinchMetrics, pinchZoom, anchorScroll, clampZoom } from "./gestures";
GlobalWorkerOptions.workerSrc = worker;
const $ = <T extends HTMLElement = HTMLElement>(s: string) =>
  document.querySelector<T>(s)!;
const icon = (name: string) => `<i data-lucide="${name}"></i>`;
const tools = [
  ["select", "mouse-pointer-2", "Select", "V"],
  ["hand", "hand", "Pan", "H"],
  ["zoom", "zoom-in", "Zoom", "Z"],
  ["ink", "pen-line", "Pen", "P"],
  ["highlight", "highlighter", "Highlight", "M"],
  ["square", "square", "Rectangle", "R"],
  ["circle", "circle", "Ellipse", "O"],
  ["line", "move-up-right", "Line", "L"],
  ["freetext", "type", "Text", "T"],
  ["eraser", "eraser", "Erase", "E"],
];
const colors = [
  ["#E44234", "Red"],
  ["#2563EB", "Blue"],
  ["#16845B", "Green"],
  ["#20282B", "Graphite"],
  ["#9B45CF", "Purple"],
  ["#ECA51C", "Amber"],
];
$("#app").innerHTML = `
<header><div class="brand">${icon("scan-line")}<b>clickmark<span>.</span></b><span class="demo">WORKSPACE DEMO</span></div><div class="document-name">${icon("file-text")}<span>demo.pdf<small>A3 landscape · 1 page</small></span></div><div class="save-area"><button id="toggle-sidebar" aria-label="Toggle layers panel" aria-expanded="true" title="Show or hide drawing review">${icon("layers")}<span>Layers</span></button><span id="save-status" role="status">Opening drawing…</span><button id="save" class="primary">${icon("save")} Save layer <span id="save-number">1</span></button></div></header>
<div class="workspace"><nav class="tools" aria-label="Annotation tools">${tools.map(([id, ic, name, key]) => `<button data-tool="${id}" aria-label="${name}" title="${name} (${key})">${icon(ic)}<span>${name}</span></button>`).join("")}<div class="tool-divider"></div><button id="stamp-tool" aria-label="Stamps" title="Choose a stamp">${icon("stamp")}<span>Stamp</span></button><button id="image-tool" aria-label="Add image" title="Add PNG, JPEG or WebP image">${icon("image-plus")}<span>Image</span></button></nav>
<main class="drawing"><div class="properties"><div class="color-options" aria-label="Ink color">${colors.map(([hex, name]) => `<button class="swatch" data-color="${hex}" style="--swatch:${hex}" aria-label="${name}" title="${name}" aria-pressed="false"></button>`).join("")}<input id="custom-color" type="color" value="#e44234" aria-label="Custom color" title="Custom color"></div><span class="separator"></span><span class="field-caption">Stroke</span><div class="width-options">${[0.5, 1, 2, 4, 8].map((n) => `<button data-width="${n}" aria-label="${n} pt stroke">${n}<span>pt</span></button>`).join("")}</div><div class="history"><button id="undo" aria-label="Undo" title="Undo (⌘Z)">${icon("undo-2")}</button><button id="redo" aria-label="Redo" title="Redo (⌘⇧Z)">${icon("redo-2")}</button></div></div>
<div class="stage" id="stage" aria-label="Drawing workspace"><div class="page" id="page"><canvas id="pdf-canvas"></canvas><svg id="overlay" xmlns="http://www.w3.org/2000/svg" aria-label="Annotation canvas"><g id="annotation-list"></g><g id="preview"></g><g id="selection"></g></svg><div id="selection-label" hidden></div></div><div id="loading">Opening your drawing…</div></div>
<div class="viewbar"><span id="tool-hint">Pen · Draw on the page</span><div class="zoom-controls"><button id="zoom-out" aria-label="Zoom out">${icon("minus")}</button><label class="zoom-input"><input id="zoom" aria-label="Zoom percent" type="number" min="25" max="1600" value="100"><span>%</span></label><button id="zoom-in" aria-label="Zoom in">${icon("plus")}</button><button id="fit" title="Fit page">${icon("scan")} Fit</button><button id="actual" title="Actual size">100%</button></div><span class="page-count">1 / 1</span></div></main>
<aside><div class="aside-heading"><div><span class="eyebrow">DRAWING REVIEW</span><h1>Layers</h1></div><button id="close-sidebar" aria-label="Collapse drawing review" title="Collapse drawing review">${icon("x")}</button></div><div class="active-summary"><span class="live-dot"></span><div>Editing layer <strong id="active-number">1</strong><small>Other layers are locked</small></div></div><div id="layers"></div><div class="layer-help">${icon("info")}<p>Select a layer to edit it. Use the eye to show or hide its marks.</p></div><section class="inspector"><h2 id="inspector-title">Pen settings</h2><label id="constrain-label" class="touch-option"><input id="constrain" type="checkbox"> Perfect shape / snap line</label><small id="constrain-help">Square, circle, or 45° line · also hold Shift</small><div id="selection-info">Select a mark to move or resize it.</div><label class="stack-label">Opacity <input id="opacity" type="range" min="10" max="100" value="100"><output id="opacity-value">100%</output></label><label class="stack-label">Text size <select id="font-size"><option>12</option><option selected>16</option><option>24</option><option>32</option><option>48</option></select></label><label class="stack-label">Text <textarea id="text-value" rows="2" placeholder="Write a review note…">Review note</textarea></label><button id="apply-text">Apply text</button><button id="delete" class="danger" disabled>${icon("trash-2")} Delete annotation</button></section><div class="files"><h2>PDF downloads</h2><a class="file-download" href="/api/pdf/original">${icon("download")} Original PDF</a><a id="download-pdf" class="file-download" href="/api/pdf/flattened" aria-disabled="true">${icon("download")} Annotated PDF</a><small id="pdf-status" role="status">Checking annotated PDF…</small><h2>Annotation files</h2><button id="import">${icon("upload")} Import XFDF</button><a id="export" class="file-download" href="/api/layers/1/download">${icon("download")} Download saved XFDF</a><details><summary>Compatibility samples</summary><p>Original EWPC marks from other drawings. Import appends them to this layer.</p><select id="sample" aria-label="XFDF sample"><option value="">Choose sample…</option></select><button id="load-sample">Import sample</button></details><label class="touch-option"><input id="touch-draw" type="checkbox"> Draw with finger</label><small>Two fingers to zoom · one to pan</small></div><footer>LOCAL DEMO <span>6 independent XFDF layers</span></footer></aside></div>
<div id="toast" role="status" aria-live="polite" hidden></div><dialog id="stamps"><div class="dialog-heading"><div><span class="eyebrow">QUICK STAMPS</span><h2>Place a stamp</h2></div><button id="close-stamps" aria-label="Close stamps">${icon("x")}</button></div><p>Choose an image, then click the drawing to place it.</p><div id="stamp-grid"></div><small>Demo collection extracted from the supplied EWPC XFDF files.</small></dialog><input type="file" id="xfdf-file" accept=".xfdf,application/xml,text/xml" hidden><input type="file" id="image-file" accept="image/png,image/jpeg,image/webp" hidden>`;
createIcons({ icons });
const stage = $("#stage"),
  pageEl = $("#page"),
  overlay = $<SVGSVGElement & HTMLElement>("#overlay");
const annGroup = document.querySelector<SVGGElement>("#annotation-list")!,
  preview = document.querySelector<SVGGElement>("#preview")!,
  selection = document.querySelector<SVGGElement>("#selection")!;
let active = /^\/[1-6]\/?$/.test(location.pathname)
  ? Number(location.pathname.split("/")[1])
  : 1;
let layers: Layer[] = [],
  vp: Viewport,
  page: PDFPageProxy,
  zoom = 1,
  tool = "ink",
  color = "#E44234",
  width = 2,
  opacity = 1,
  fontSize = 16,
  selected: Annotation | undefined,
  ready = false,
  saving = false;
let imageToPlace: string | undefined,
  imageAspect = 1,
  renderTask: RenderTask | undefined,
  renderTimer: ReturnType<typeof setTimeout>,
  renderGeneration = 0;
const selectedIds = new Set<string>();
const pointers = new Map<number, Point>();
let gesture: { distance: number; zoom: number; point: Point } | undefined;
let action:
  | {
      type: string;
      start: Point;
      original?: Annotation;
      draft?: Annotation;
      originals?: Annotation[];
      drafts?: Annotation[];
      marquee?: Rect;
      additive?: boolean;
      before: Annotation[];
      client: Point;
      scroll: Point;
      pointerId: number;
    }
  | undefined;
let fitting = true;
let raf = 0,
  toastTimer: ReturnType<typeof setTimeout>;
const current = () => layers[active - 1];
const dirty = (l: Layer) => JSON.stringify(l.annotations) !== l.saved;
const textMeasure = document.createElement("canvas").getContext("2d")!;
function textWidth(text: string, size: number) {
  textMeasure.font = `400 ${size}px "Clickmark Noto"`;
  return Math.max(
    50,
    ...text.split("\n").map((line) => textMeasure.measureText(line).width + 4),
  );
}
function recordDiagnostic(message: string) {
  console.warn("Clickmark drawing check:", message);
  try {
    const key = "clickmark-diagnostics";
    const previous = JSON.parse(localStorage.getItem(key) || "[]");
    localStorage.setItem(
      key,
      JSON.stringify(
        [
          ...previous,
          { time: new Date().toISOString(), path: location.pathname, message },
        ].slice(-100),
      ),
    );
  } catch {
    /* Diagnostics must never interrupt drawing or saving. */
  }
}
function toast(message: string, error = false) {
  if (error) recordDiagnostic(message);
  const el = $("#toast");
  el.textContent = message;
  el.hidden = false;
  el.classList.toggle("error", error);
  clearTimeout(toastTimer);
  toastTimer = setTimeout(() => (el.hidden = true), error ? 12000 : 5000);
}
function svg<K extends keyof SVGElementTagNameMap>(
  name: K,
  attrs: Record<string, string | number> = {},
): SVGElementTagNameMap[K] {
  const e = document.createElementNS("http://www.w3.org/2000/svg", name);
  for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, String(v));
  return e;
}
function graphic(
  a: Annotation,
  interactive = true,
  smooth = false,
): SVGGElement {
  const group = svg("g", {
    "data-id": a.id,
    "data-layer": a.layer,
    "aria-label": `${a.kind} · layer ${a.layer}${a.layer !== active ? " · locked" : ""}`,
  });
  group.style.opacity = String(a.opacity);
  const [x, y, w, h] = a.rect;
  const style = {
    stroke: a.color,
    "stroke-width": a.width,
    fill: "none",
    "stroke-linecap": "round",
    "stroke-linejoin": "round",
  };
  let shape: SVGElement;
  if (a.kind === "ink" || a.kind === "line") {
    shape = svg("path", {
      ...style,
      d: (a.paths || [])
        .map((p) => inkPath(p, smooth && a.kind === "ink"))
        .join(" "),
    });
  } else if (a.kind === "square") {
    shape = svg("rect", { ...style, x, y, width: w, height: h });
  } else if (a.kind === "circle") {
    shape = svg("ellipse", {
      ...style,
      cx: x + w / 2,
      cy: y + h / 2,
      rx: w / 2,
      ry: h / 2,
    });
  } else if (a.kind === "freetext") {
    shape = svg("text", {
      x,
      y: y + (a.fontSize || 16),
      fill: a.color,
      "font-size": a.fontSize || 16,
      "font-family": '"Clickmark Noto", sans-serif',
    });
    for (const [i, line] of (a.text || "").split("\n").entries()) {
      const t = svg("tspan", { x, dy: i ? (a.fontSize || 16) * 1.3 : 0 });
      t.textContent = line;
      shape.append(t);
    }
  } else if (a.kind === "stamp" && a.image) {
    shape = svg("image", {
      x,
      y,
      width: w,
      height: h,
      href: a.image,
      preserveAspectRatio: "none",
    });
  } else {
    shape = svg("rect", {
      x,
      y,
      width: w,
      height: h,
      fill: "#d8d8d822",
      stroke: "#969b9f",
      "stroke-dasharray": "4 4",
    });
  }
  group.append(shape);
  if (interactive) {
    const hit = shape.cloneNode(true) as SVGElement;
    if (["ink", "line", "square", "circle"].includes(a.kind)) {
      hit.setAttribute("stroke", "transparent");
      hit.setAttribute("stroke-width", String(Math.max(a.width, 10 / zoom)));
      hit.setAttribute("fill", "none");
      hit.style.pointerEvents = "stroke";
    } else {
      const hitbox = svg("rect", {
        x,
        y,
        width: Math.max(w, 8),
        height: Math.max(h, 8),
        fill: "transparent",
      });
      group.append(hitbox);
      hitbox.style.pointerEvents = "all";
      return group;
    }
    group.append(hit);
  }
  return group;
}
function renderAnnotations() {
  annGroup.replaceChildren();
  for (const l of layers)
    if (l.visible)
      for (const a of l.annotations)
        if (a.page === 0) annGroup.append(graphic(a));
  renderSelection();
}
function clearSelection() {
  selected = undefined;
  selectedIds.clear();
}
function selectionMarks(): Annotation[] {
  if (action?.type === "groupmove") return action.drafts || [];
  if (selectedIds.size)
    return current()?.visible
      ? current().annotations.filter(
          (a) => selectedIds.has(a.id) && canEdit(a, active),
        )
      : [];
  return selected && layers[selected.layer - 1]?.visible ? [selected] : [];
}
function chooseMany(marks: Annotation[]) {
  clearSelection();
  for (const a of marks)
    if (canEdit(a, active) && current().visible) selectedIds.add(a.id);
  if (selectedIds.size === 1) {
    const a = marks.find((a) => selectedIds.has(a.id));
    choose(a);
    return;
  }
  const picked = selectionMarks();
  const colored = picked.filter((a) => a.kind !== "stamp");
  if (
    colored.length &&
    colored.every(
      (a) => a.color.toLowerCase() === colored[0].color.toLowerCase(),
    )
  )
    color = colored[0].color;
  if (picked.length && picked.every((a) => a.width === picked[0].width))
    width = picked[0].width;
  if (picked.length && picked.every((a) => a.opacity === picked[0].opacity)) {
    opacity = picked[0].opacity;
    $<HTMLInputElement>("#opacity").value = String(opacity * 100);
    $("#opacity-value").textContent = `${Math.round(opacity * 100)}%`;
  }
  renderSelection();
  updateUI();
}
function renderSelection() {
  selection.replaceChildren();
  const label = $("#selection-label");
  label.hidden = true;
  const marks = selectionMarks();
  if (!marks.length) return;
  const multiple = marks.length > 1;
  const [x, y, w, h] = multiple ? selectionBounds(marks) : marks[0].rect,
    pad = 4 / zoom,
    locked = !canEdit(marks[0], active);
  const box = svg("rect", {
    x: x - pad,
    y: y - pad,
    width: w + 2 * pad,
    height: h + 2 * pad,
    fill: multiple ? "#24664e0b" : "none",
    stroke: locked ? "#a16c18" : "#24664e",
    "stroke-width": 1.5 / zoom,
    "stroke-dasharray": locked ? `${5 / zoom} ${3 / zoom}` : "",
  });
  selection.append(box);
  if (multiple)
    for (const mark of marks)
      selection.append(
        svg("rect", {
          x: mark.rect[0],
          y: mark.rect[1],
          width: mark.rect[2],
          height: mark.rect[3],
          fill: "none",
          stroke: "#24664e",
          "stroke-width": 1 / zoom,
          "stroke-dasharray": `${3 / zoom} ${3 / zoom}`,
          "data-member": mark.id,
        }),
      );
  label.textContent = multiple
    ? `${marks.length} selected · Layer ${active}`
    : `${locked ? "Locked · " : "Editing · "}Layer ${marks[0].layer}`;
  label.hidden = false;
  label.classList.toggle("locked", locked);
  label.style.left = `${Math.max(0, Math.min(vp.width * zoom - 140, (x + w) * zoom - 130))}px`;
  label.style.top = `${Math.max(0, y * zoom - 28)}px`;
  if (!locked && !multiple) {
    const handle = svg("rect", {
      x: x + w - 5 / zoom,
      y: y + h - 5 / zoom,
      width: 10 / zoom,
      height: 10 / zoom,
      fill: "#24664e",
      stroke: "white",
      "stroke-width": 1 / zoom,
      "data-resize": "true",
    });
    handle.style.pointerEvents = "all";
    handle.style.cursor = "nwse-resize";
    selection.append(handle);
  }
}
function updateUI() {
  $("#active-number").textContent = String(active);
  $("#save-number").textContent = String(active);
  $<HTMLButtonElement>("#save").disabled = !ready || saving;
  $("#save-status").textContent = !ready
    ? "Opening drawing…"
    : saving
      ? "Saving…"
      : dirty(current())
        ? "Unsaved changes"
        : "All changes saved";
  $("#save-status").classList.toggle("unsaved", ready && dirty(current()));
  for (const b of document.querySelectorAll<HTMLButtonElement>("[data-tool]")) {
    const yes = b.dataset.tool === tool;
    b.classList.toggle("active", yes);
    b.setAttribute("aria-pressed", String(yes));
  }
  const colorMarks = selectionMarks().filter((a) => a.kind !== "stamp");
  const mixedColor = colorMarks.some(
    (a) => a.color.toLowerCase() !== colorMarks[0].color.toLowerCase(),
  );
  for (const b of document.querySelectorAll<HTMLButtonElement>("[data-color]"))
    b.setAttribute(
      "aria-pressed",
      String(
        !mixedColor && b.dataset.color?.toLowerCase() === color.toLowerCase(),
      ),
    );
  for (const b of document.querySelectorAll<HTMLButtonElement>("[data-width]"))
    b.classList.toggle("chosen", Number(b.dataset.width) === width);
  $("#stamp-tool").classList.toggle("active", tool === "stamp");
  $("#image-tool").classList.toggle("active", tool === "image");
  const hints: Record<string, string> = {
    ink: "Pen · Draw on the page",
    highlight: "Highlight · Draw a translucent stroke",
    select:
      "Select · Drag around marks · Shift-click to add/remove · Drag selection to move",
    hand: "Pan · Drag the drawing",
    zoom: "Zoom · Click to zoom in · Alt-click to zoom out",
    eraser: "Erase · Click or sweep over marks in your layer",
    freetext: "Text · Click to place your note",
    square: "Rectangle · Drag to draw",
    circle: "Ellipse · Drag to draw",
    line: "Line · Drag to draw",
    stamp: "Stamp · Click to place",
    image: "Image · Click to place",
  };
  $("#tool-hint").textContent = hints[tool];
  overlay.style.cursor =
    tool === "zoom"
      ? "zoom-in"
      : ["hand"].includes(tool)
        ? "grab"
        : ["select"].includes(tool)
          ? "default"
          : "crosshair";
  if (!ready) return;
  $("#layers").replaceChildren(
    ...layers.map((l) => {
      const row = document.createElement("div");
      row.className = `layer-row ${l.id === active ? "editing" : ""}`;
      const edit = document.createElement("button");
      edit.className = "layer-select";
      edit.setAttribute("aria-label", `Edit layer ${l.id}`);
      edit.setAttribute("aria-pressed", String(l.id === active));
      edit.innerHTML = `<span class="layer-number">${l.id}</span><span><b>Layer ${l.id}${dirty(l) ? ' <span class="dirty-dot">•</span>' : ""}</b><small>${l.annotations.length} mark${l.annotations.length === 1 ? "" : "s"} · ${l.id === active ? "Editing" : "Locked"}</small></span>${icon(l.id === active ? "pencil" : "lock-keyhole")}`;
      edit.onclick = () => switchLayer(l.id);
      const eye = document.createElement("button");
      eye.className = "eye";
      eye.setAttribute(
        "aria-label",
        `${l.visible ? "Hide" : "Show"} layer ${l.id}`,
      );
      eye.setAttribute("aria-pressed", String(l.visible));
      eye.innerHTML = icon(l.visible ? "eye" : "eye-off");
      eye.onclick = () => {
        if (action) cancelAction();
        l.visible = !l.visible;
        if (!l.visible && (l.id === active || selected?.layer === l.id))
          clearSelection();
        renderAnnotations();
        updateUI();
      };
      row.append(edit, eye);
      return row;
    }),
  );
  $<HTMLAnchorElement>("#export").href = `/api/layers/${active}/download`;
  $("#export").setAttribute("aria-disabled", String(dirty(current())));
  const constrainedTool =
    !selected && ["square", "circle", "line"].includes(tool);
  $("#constrain-label").hidden = !constrainedTool;
  $("#constrain-help").hidden = !constrainedTool;
  const marks = selectionMarks(),
    multi = marks.length > 1;
  const locked = selected && !canEdit(selected, active);
  const textMode =
    selected?.kind === "freetext" || (!selected && tool === "freetext");
  $("#font-size").closest("label")!.hidden = !textMode;
  $("#text-value").closest("label")!.hidden = !textMode;
  $("#apply-text").hidden = !textMode;
  $<HTMLInputElement>("#opacity").disabled = Boolean(locked);
  $("#selection-info").textContent = multi
    ? `${marks.length} marks · Layer ${active}. Move, delete, or recolor together. Images retain their colors.`
    : selected
      ? `${selected.kind === "unknown" ? "Preserved annotation" : selected.kind} · Layer ${selected.layer}${locked ? " · Locked" : ""}`
      : "Select a mark to move or resize it.";
  $("#inspector-title").textContent = multi
    ? `${marks.length} selected annotations`
    : selected
      ? "Selected annotation"
      : `${tools.find((t) => t[0] === tool)?.[2] || tool} settings`;
  $<HTMLButtonElement>("#delete").disabled = !marks.length || Boolean(locked);
  $("#delete").innerHTML =
    `${icon("trash-2")} ${multi ? `Delete ${marks.length} annotations` : "Delete annotation"}`;
  $<HTMLButtonElement>("#apply-text").disabled =
    !!selected && (selected.kind !== "freetext" || !!locked);
  $<HTMLButtonElement>("#undo").disabled = !current().undo.length;
  $<HTMLButtonElement>("#redo").disabled = !current().redo.length;
  createIcons({ icons });
}
function switchLayer(n: number, push = true) {
  if (action) return;
  active = n;
  clearSelection();
  current().visible = true;
  if (push) history.pushState({}, "", `/${n}`);
  renderAnnotations();
  updateUI();
}
function setTool(t: string) {
  if (action) cancelAction();
  tool = t;
  overlay.style.cursor =
    t === "zoom" ? "zoom-in" : t === "hand" ? "grab" : "crosshair";
  clearSelection();
  renderSelection();
  updateUI();
}
function commit(next: Annotation[]) {
  change(current(), next);
  renderAnnotations();
  updateUI();
}
function updateSelected(patch: Partial<Annotation>) {
  if (action) return;
  const marks = selectionMarks();
  if (marks.length > 1) {
    const ids = new Set(marks.map((a) => a.id));
    const next = styleSelection(current().annotations, ids, active, patch);
    if (next.some((a, i) => a !== current().annotations[i])) commit(next);
    return;
  }
  if (!selected || !canEdit(selected, active)) return;
  const updated = { ...selected, ...patch, modified: true };
  commit(
    current().annotations.map((a) => (a.id === selected!.id ? updated : a)),
  );
  selected = updated;
  renderSelection();
  updateUI();
}
function removeSelected() {
  if (!ready || action) return;
  const ids = new Set(
    selectionMarks()
      .filter((a) => canEdit(a, active))
      .map((a) => a.id),
  );
  const next = deleteSelection(current().annotations, ids, active);
  if (next.length !== current().annotations.length) {
    clearSelection();
    commit(next);
  }
}
function choose(a: Annotation | undefined) {
  selectedIds.clear();
  selected = a;
  if (a) {
    color = a.color;
    width = a.width;
    opacity = a.opacity;
    $<HTMLInputElement>("#opacity").value = String(opacity * 100);
    $("#opacity-value").textContent = `${Math.round(opacity * 100)}%`;
    if (a.kind === "freetext") {
      $<HTMLTextAreaElement>("#text-value").value = a.text || "";
      fontSize = a.fontSize || 16;
    }
  }
  renderSelection();
  updateUI();
}
function point(e: { clientX: number; clientY: number }): Point {
  return clientToDocument(
    e.clientX,
    e.clientY,
    overlay.getBoundingClientRect(),
    vp,
  );
}
function hit(target: EventTarget | null): Annotation | undefined {
  const id = (target as Element)
      ?.closest?.("[data-id]")
      ?.getAttribute("data-id"),
    layer = Number(
      (target as Element)
        ?.closest?.("[data-layer]")
        ?.getAttribute("data-layer"),
    );
  return layers[layer - 1]?.annotations.find((a) => a.id === id);
}
function newAnnotation(kind: Annotation["kind"], p: Point): Annotation {
  return {
    id: crypto.randomUUID(),
    layer: active,
    kind,
    page: 0,
    rect: [...p, 0, 0],
    color,
    width: tool === "highlight" ? Math.max(width, 12) : width,
    opacity: tool === "highlight" ? 0.3 : opacity,
    modified: true,
  };
}
function repaintDraft() {
  if (raf) return;
  raf = requestAnimationFrame(() => {
    raf = 0;
    preview.replaceChildren();
    if (action?.type === "marquee" && action.marquee) {
      const [x, y, w, h] = action.marquee;
      preview.append(
        svg("rect", {
          x,
          y,
          width: w,
          height: h,
          fill: "#24664e18",
          stroke: "#24664e",
          "stroke-width": 1 / zoom,
          "stroke-dasharray": `${5 / zoom} ${3 / zoom}`,
          "data-marquee": "true",
        }),
      );
    }
    if (action?.type === "groupmove") {
      for (const a of action.drafts || []) {
        preview.append(graphic(a, false));
        const old = Array.from(annGroup.children).find(
          (e) =>
            e.getAttribute("data-id") === a.id &&
            Number(e.getAttribute("data-layer")) === active,
        );
        if (old) (old as SVGElement).style.visibility = "hidden";
      }
      renderSelection();
    }
    if (action?.draft)
      preview.append(graphic(action.draft, false, action.type === "draw"));
    if (action?.type === "move" || action?.type === "resize") {
      const a = action.draft;
      if (a) {
        const old = Array.from(annGroup.children).find(
          (e) =>
            e.getAttribute("data-id") === a.id &&
            Number(e.getAttribute("data-layer")) === a.layer,
        );
        if (old) (old as SVGElement).style.visibility = "hidden";
        selected = a;
        renderSelection();
      }
    }
  });
}
function cancelAction() {
  if (action?.type === "erase") current().annotations = action.before;
  if (action?.original) selected = action.original;
  action = undefined;
  preview.replaceChildren();
  renderAnnotations();
}
overlay.addEventListener("pointerdown", (e) => {
  if (!ready || e.button !== 0) return;
  e.preventDefault();
  pointers.set(e.pointerId, [e.clientX, e.clientY]);
  overlay.setPointerCapture(e.pointerId);
  if (pointers.size === 2) {
    cancelAction();
    const { midpoint: mid, distance } = pinchMetrics([...pointers.values()]);
    gesture = {
      distance,
      zoom,
      point: point({ clientX: mid[0], clientY: mid[1] }),
    };
    return;
  }
  if (gesture || pointers.size > 1) return;
  const p = point(e),
    a = hit(e.target),
    before = clone(current().annotations);
  const base = {
    start: p,
    before,
    client: [e.clientX, e.clientY] as Point,
    scroll: [stage.scrollLeft, stage.scrollTop] as Point,
    pointerId: e.pointerId,
  };
  if (
    tool === "hand" ||
    (e.pointerType === "touch" && !$<HTMLInputElement>("#touch-draw").checked)
  ) {
    action = { ...base, type: "pan" };
    return;
  }
  if (tool === "zoom") {
    setZoom(zoom * (e.altKey ? 1 / 1.5 : 1.5), [e.clientX, e.clientY], p);
    return;
  }
  if (tool === "select") {
    if (
      (e.target as Element).hasAttribute("data-resize") &&
      selected &&
      canEdit(selected, active)
    ) {
      action = {
        ...base,
        type: "resize",
        original: clone(selected),
        draft: clone(selected),
      };
      return;
    }
    const marks = selectionMarks().filter((mark) => canEdit(mark, active));
    if (e.shiftKey && a && canEdit(a, active)) {
      chooseMany(
        marks.some((mark) => mark.id === a.id)
          ? marks.filter((mark) => mark.id !== a.id)
          : [...marks, a],
      );
      return;
    }
    if (
      marks.length > 1 &&
      !e.shiftKey &&
      (a
        ? marks.some((mark) => mark.id === a.id)
        : containsRect(selectionBounds(marks), [p[0], p[1], 0, 0]))
    ) {
      action = {
        ...base,
        type: "groupmove",
        originals: clone(marks),
        drafts: clone(marks),
      };
      return;
    }
    if (!a) {
      action = { ...base, type: "marquee", additive: e.shiftKey };
      return;
    }
    if (e.shiftKey && !canEdit(a, active)) {
      toast(`Layer ${a.layer} is locked and cannot join this selection.`);
      return;
    }
    choose(a);
    if (canEdit(a, active))
      action = { ...base, type: "move", original: clone(a), draft: clone(a) };
    else
      toast(`Locked · Layer ${a.layer}. Select that layer to edit its marks.`);
    return;
  }
  if (tool === "eraser") {
    action = { ...base, type: "erase" };
    if (a && canEdit(a, active)) {
      current().annotations = current().annotations.filter(
        (b) => b.id !== a.id,
      );
      renderAnnotations();
    } else if (a) toast(`Layer ${a.layer} is locked.`);
    return;
  }
  if (!current().visible) {
    current().visible = true;
    renderAnnotations();
  }
  if (["stamp", "image"].includes(tool)) {
    if (!imageToPlace) {
      toast("Choose an image first.");
      return;
    }
    const a = newAnnotation("stamp", p);
    a.rect = [p[0], p[1], 100, 100 / imageAspect];
    a.image = imageToPlace;
    commit([...current().annotations, a]);
    setTool("select");
    choose(a);
    return;
  }
  if (tool === "freetext") {
    const text =
      $<HTMLTextAreaElement>("#text-value").value.trim() || "Review note";
    const a = newAnnotation("freetext", p);
    a.fontSize = fontSize;
    a.text = text;
    a.rect = [
      p[0],
      p[1],
      textWidth(text, fontSize),
      text.split("\n").length * fontSize * 1.3 + 4,
    ];
    commit([...current().annotations, a]);
    setTool("select");
    choose(a);
    return;
  }
  const draft = newAnnotation(
    tool === "highlight" ? "ink" : (tool as Annotation["kind"]),
    p,
  );
  if (draft.kind === "ink" || draft.kind === "line") draft.paths = [[p]];
  action = { ...base, type: "draw", draft };
  repaintDraft();
});
overlay.addEventListener("pointermove", (e) => {
  if (!pointers.has(e.pointerId)) return;
  pointers.set(e.pointerId, [e.clientX, e.clientY]);
  if (gesture && pointers.size >= 2) {
    const { midpoint: mid, distance } = pinchMetrics([...pointers.values()]);
    setZoom(
      pinchZoom(gesture.zoom, gesture.distance, distance),
      mid,
      gesture.point,
    );
    return;
  }
  if (!action || action.pointerId !== e.pointerId) return;
  if (action.type === "pan") {
    stage.scrollLeft = action.scroll[0] - (e.clientX - action.client[0]);
    stage.scrollTop = action.scroll[1] - (e.clientY - action.client[1]);
    return;
  }
  const p = point(e),
    dx = p[0] - action.start[0],
    dy = p[1] - action.start[1];
  if (action.type === "marquee") {
    action.marquee = bounds([action.start, p]);
    repaintDraft();
    return;
  }
  if (action.type === "groupmove") {
    action.drafts = moveSelection(
      action.originals!,
      new Set(action.originals!.map((a) => a.id)),
      active,
      dx,
      dy,
    );
    repaintDraft();
    return;
  }
  if (action.type === "erase") {
    const a = hit(document.elementFromPoint(e.clientX, e.clientY));
    if (a && canEdit(a, active)) {
      current().annotations = current().annotations.filter(
        (b) => b.id !== a.id,
      );
      renderAnnotations();
    }
    return;
  }
  if (action.type === "move")
    action.draft = translate(action.original!, dx, dy);
  else if (action.type === "resize") {
    const [x, y, w, h] = action.original!.rect;
    let nw = Math.max(8, w + dx),
      nh = Math.max(8, h + dy);
    if (["stamp", "freetext"].includes(action.original!.kind)) {
      const ratio = w / h;
      nh = nw / ratio;
    }
    action.draft = resize(action.original!, [x, y, nw, nh]);
  } else if (action.draft) {
    const a = action.draft;
    if (a.kind === "ink") {
      const events = e.getCoalescedEvents?.() || [];
      for (const event of events.length ? events : [e]) {
        const q = point(event),
          last = a.paths![0].at(-1)!;
        if (Math.hypot(q[0] - last[0], q[1] - last[1]) > 0.25)
          a.paths![0].push(q);
      }
      a.rect = bounds(a.paths![0], a.width / 2);
    } else {
      const end = shapeEnd(
        action.start,
        p,
        a.kind,
        e.shiftKey || $<HTMLInputElement>("#constrain").checked,
      );
      a.rect = bounds([action.start, end]);
      if (a.kind === "line") a.paths = [[action.start, end]];
    }
  }
  repaintDraft();
});
function pointerEnd(e: PointerEvent, cancel = false) {
  pointers.delete(e.pointerId);
  if (overlay.hasPointerCapture(e.pointerId))
    overlay.releasePointerCapture(e.pointerId);
  if (gesture) {
    if (pointers.size === 0) gesture = undefined;
    return;
  }
  if (!action || action.pointerId !== e.pointerId) return;
  if (cancel) {
    current().annotations = action.before;
    cancelAction();
    updateUI();
    return;
  }
  const a = action;
  action = undefined;
  preview.replaceChildren();
  if (a.type === "marquee") {
    const enclosed = a.marquee
      ? selectEnclosed(
          current().annotations,
          a.marquee,
          active,
          current().visible,
        )
      : [];
    chooseMany(
      a.additive
        ? [
            ...selectionMarks().filter((mark) => canEdit(mark, active)),
            ...enclosed,
          ]
        : enclosed,
    );
  } else if (a.type === "groupmove") {
    if (
      a.drafts!.some(
        (mark, i) =>
          JSON.stringify(mark.rect) !== JSON.stringify(a.originals![i].rect),
      )
    ) {
      const drafts = new Map(a.drafts!.map((mark) => [mark.id, mark]));
      commit(current().annotations.map((mark) => drafts.get(mark.id) || mark));
    }
  } else if (a.type === "erase") {
    const next = clone(current().annotations);
    current().annotations = a.before;
    if (JSON.stringify(next) !== JSON.stringify(a.before)) commit(next);
  } else if (a.draft) {
    if (a.type === "draw") {
      if (a.draft.kind === "ink") {
        a.draft.paths = [smoothPoints(a.draft.paths![0])];
        a.draft.rect = bounds(a.draft.paths![0], a.draft.width / 2);
        commit([...current().annotations, a.draft]);
      } else if (a.draft.rect[2] > 1 || a.draft.rect[3] > 1)
        commit([...current().annotations, a.draft]);
    } else if (
      JSON.stringify(a.original?.rect) !== JSON.stringify(a.draft.rect)
    ) {
      selected = a.draft;
      commit(
        current().annotations.map((b) => (b.id === a.draft!.id ? a.draft! : b)),
      );
    }
  }
  renderAnnotations();
  updateUI();
}
overlay.addEventListener("pointerup", (e) => pointerEnd(e));
overlay.addEventListener("pointercancel", (e) => pointerEnd(e, true));
function setZoom(next: number, anchor?: Point, docPoint?: Point) {
  if (!vp || !Number.isFinite(next)) return;
  fitting = false;
  const box = stage.getBoundingClientRect();
  anchor ??= [box.left + box.width / 2, box.top + box.height / 2];
  docPoint ??= point({ clientX: anchor[0], clientY: anchor[1] });
  zoom = clampZoom(next);
  pageEl.style.width = `${vp.width * zoom}px`;
  pageEl.style.height = `${vp.height * zoom}px`;
  const now = pageEl.getBoundingClientRect();
  const delta = anchorScroll([now.left, now.top], docPoint, zoom, anchor);
  stage.scrollLeft += delta[0];
  stage.scrollTop += delta[1];
  $<HTMLInputElement>("#zoom").value = String(Math.round(zoom * 100));
  renderSelection();
  clearTimeout(renderTimer);
  renderTimer = setTimeout(() => void renderPdf(), 140);
}
function fit() {
  setZoom(
    Math.min(
      (stage.clientWidth - 64) / vp.width,
      (stage.clientHeight - 64) / vp.height,
    ),
  );
  stage.scrollTo(0, 0);
  fitting = true;
}
async function renderPdf() {
  const generation = ++renderGeneration;
  renderTask?.cancel();
  const viewport = page.getViewport({ scale: zoom });
  const raster = Math.min(
    window.devicePixelRatio || 1,
    Math.sqrt(18_000_000 / (viewport.width * viewport.height)),
  );
  const canvas = document.createElement("canvas");
  canvas.id = "pdf-canvas";
  canvas.width = Math.round(viewport.width * raster);
  canvas.height = Math.round(viewport.height * raster);
  try {
    renderTask = page.render({
      canvas,
      viewport,
      transform: [raster, 0, 0, raster, 0, 0],
    });
    await renderTask.promise;
    if (generation === renderGeneration) {
      $("#pdf-canvas").replaceWith(canvas);
      $("#loading").hidden = true;
      pageEl.dataset.renderedZoom = String(zoom);
    }
  } catch (e) {
    if ((e as Error).name !== "RenderingCancelledException")
      toast("PDF rendering failed: " + (e as Error).message, true);
  }
}
stage.addEventListener(
  "wheel",
  (e) => {
    if (e.ctrlKey || e.metaKey) {
      e.preventDefault();
      setZoom(zoom * Math.exp(-e.deltaY * 0.008), [e.clientX, e.clientY]);
    }
  },
  { passive: false },
);
$("#zoom-out").onclick = () => setZoom(zoom / 1.25);
$("#zoom-in").onclick = () => setZoom(zoom * 1.25);
$("#fit").onclick = fit;
$("#actual").onclick = () => setZoom(1);
$<HTMLInputElement>("#zoom").onchange = (e) =>
  setZoom(Number((e.target as HTMLInputElement).value) / 100);
for (const b of document.querySelectorAll<HTMLButtonElement>("[data-tool]"))
  b.onclick = () => setTool(b.dataset.tool!);
for (const b of document.querySelectorAll<HTMLButtonElement>("[data-color]"))
  b.onclick = () => {
    color = b.dataset.color!;
    updateSelected({ color });
    updateUI();
  };
for (const b of document.querySelectorAll<HTMLButtonElement>("[data-width]"))
  b.onclick = () => {
    width = Number(b.dataset.width);
    updateSelected({ width });
    updateUI();
  };
$<HTMLInputElement>("#custom-color").onchange = (e) => {
  color = (e.target as HTMLInputElement).value;
  updateSelected({ color });
  updateUI();
};
$<HTMLInputElement>("#opacity").oninput = (e) => {
  opacity = Number((e.target as HTMLInputElement).value) / 100;
  $("#opacity-value").textContent = `${Math.round(opacity * 100)}%`;
};
$<HTMLInputElement>("#opacity").onchange = () => updateSelected({ opacity });
$<HTMLSelectElement>("#font-size").onchange = (e) => {
  fontSize = Number((e.target as HTMLSelectElement).value);
  if (selected?.kind === "freetext") {
    updateSelected({
      fontSize,
      rect: [
        selected.rect[0],
        selected.rect[1],
        textWidth(selected.text || "", fontSize),
        (selected.text || "").split("\n").length * fontSize * 1.3 + 4,
      ],
    });
  }
};
$("#apply-text").onclick = () => {
  if (selected?.kind === "freetext") {
    const text = $<HTMLTextAreaElement>("#text-value").value;
    const rect: Rect = [
      selected.rect[0],
      selected.rect[1],
      textWidth(text, fontSize),
      text.split("\n").length * fontSize * 1.3 + 4,
    ];
    updateSelected({ text, fontSize, rect });
  } else {
    setTool("freetext");
    toast("Click the drawing to place your text.");
  }
};
$("#delete").onclick = removeSelected;
function undo(redo = false) {
  if (!ready || action) return;
  const l = current(),
    from = redo ? l.redo : l.undo,
    to = redo ? l.undo : l.redo;
  if (from.length) {
    to.push(clone(l.annotations));
    l.annotations = from.pop()!;
    clearSelection();
    renderAnnotations();
    updateUI();
  }
}
$("#undo").onclick = () => undo();
$("#redo").onclick = () => undo(true);
async function save() {
  if (!ready || saving) return;
  const l = current(),
    snapshot = JSON.stringify(l.annotations);
  saving = true;
  updateUI();
  try {
    const xml = serializeXfdf(l.annotations, l.id, vp),
      res = await fetch(`/api/layers/${l.id}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ xfdf: xml, revision: l.revision }),
      });
    if (!res.ok) throw new Error(await res.text());
    const saved = await res.json();
    l.revision = saved.revision;
    l.saved = snapshot;
    void refreshPdfStatus();
    toast(`Layer ${l.id} saved to layer-${l.id}.xfdf`);
  } catch (e) {
    toast("Save failed: " + (e as Error).message, true);
  } finally {
    saving = false;
    updateUI();
  }
}
$("#save").onclick = () => void save();
$("#export").onclick = (e) => {
  if (!ready || dirty(current())) {
    e.preventDefault();
    toast("Save this layer before downloading its XFDF file.");
  }
};

function importXml(xml: string) {
  try {
    const result = parseXfdf(xml, active, vp);
    for (const a of result.annotations) a.id = crypto.randomUUID();
    for (const warning of result.warnings) recordDiagnostic(warning);
    commit([...current().annotations, ...result.annotations]);
    toast(
      `Imported ${result.annotations.length} marks into layer ${active}.${result.warnings.length ? " " + result.warnings.join(" ") : ""}`,
    );
  } catch (e) {
    toast((e as Error).message, true);
  }
}
$("#import").onclick = () => $<HTMLInputElement>("#xfdf-file").click();
$<HTMLInputElement>("#xfdf-file").onchange = async (e) => {
  const input = e.target as HTMLInputElement,
    f = input.files?.[0];
  if (f) {
    if (f.size > 16 * 1024 * 1024) toast("XFDF exceeds 16 MiB.", true);
    else importXml(await f.text());
  }
  input.value = "";
};
$("#load-sample").onclick = async () => {
  const url = $<HTMLSelectElement>("#sample").value;
  if (url) {
    const r = await fetch(url);
    if (r.ok) importXml(await r.text());
    else toast("Could not load sample.", true);
  }
};
async function selectImage(data: string, t: string) {
  if (!safeImage(data))
    throw new Error("Choose a PNG, JPEG, or WebP image under 8 MiB.");
  const img = new Image();
  img.src = data;
  await img.decode();
  if (img.width * img.height > 32_000_000)
    throw new Error("Image dimensions exceed 32 megapixels.");
  imageAspect = img.width / img.height;
  imageToPlace = data;
  setTool(t);
  toast("Click the drawing to place the image.");
}
async function readImage(file: File, p?: Point) {
  if (
    file.size > 8 * 1024 * 1024 ||
    !["image/png", "image/jpeg", "image/webp"].includes(file.type)
  ) {
    toast("Choose a PNG, JPEG, or WebP image under 8 MiB.", true);
    return;
  }
  try {
    const data = await new Promise<string>((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = () => resolve(String(reader.result));
      reader.onerror = reject;
      reader.readAsDataURL(file);
    });
    await selectImage(data, "image");
    if (p) {
      const a = newAnnotation("stamp", p);
      a.rect = [...p, 100, 100 / imageAspect];
      a.image = data;
      commit([...current().annotations, a]);
      setTool("select");
      choose(a);
    }
  } catch (e) {
    toast("Image could not be loaded: " + (e as Error).message, true);
  }
}
$("#image-tool").onclick = () => $<HTMLInputElement>("#image-file").click();
$<HTMLInputElement>("#image-file").onchange = async (e) => {
  const input = e.target as HTMLInputElement,
    f = input.files?.[0];
  if (f) await readImage(f);
  input.value = "";
};
stage.ondragover = (e) => e.preventDefault();
stage.ondrop = (e) => {
  e.preventDefault();
  const file = e.dataTransfer?.files[0];
  if (file && ready) void readImage(file, point(e));
};
$("#stamp-tool").onclick = () => $<HTMLDialogElement>("#stamps").showModal();
$("#close-stamps").onclick = () => $<HTMLDialogElement>("#stamps").close();
window.addEventListener("keydown", (e) => {
  if ((e.target as HTMLElement).matches("input,textarea,select")) return;
  if (
    ready &&
    tool === "select" &&
    (e.metaKey || e.ctrlKey) &&
    e.key.toLowerCase() === "a"
  ) {
    e.preventDefault();
    if (!action)
      chooseMany(
        current().visible
          ? current().annotations.filter((a) => canEdit(a, active))
          : [],
      );
    return;
  }
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
    e.preventDefault();
    void save();
    return;
  }
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
    e.preventDefault();
    undo(e.shiftKey);
    return;
  }
  if (e.key === "Delete" || e.key === "Backspace") {
    e.preventDefault();
    removeSelected();
  }
  if (e.key === "Escape") {
    cancelAction();
    choose(undefined);
  }
  const found = tools.find((t) => t[3].toLowerCase() === e.key.toLowerCase());
  if (found && !e.metaKey && !e.ctrlKey) setTool(found[0]);
});
window.addEventListener("beforeunload", (e) => {
  if (layers.some(dirty)) {
    e.preventDefault();
    e.returnValue = "";
  }
});
window.addEventListener("popstate", () => {
  const n = Number(location.pathname.split("/")[1]);
  if (n >= 1 && n <= 6) switchLayer(n, false);
});
async function start() {
  try {
    const [pdf, res] = await Promise.all([
      getDocument({ url: "/demo.pdf" }).promise,
      fetch("/api/layers"),
      document.fonts.load('400 16px "Clickmark Noto"'),
    ]);
    if (!res.ok)
      throw new Error(
        "The Rust storage service is unavailable. Start npm run server.",
      );
    page = await pdf.getPage(1);
    vp = page.getViewport({ scale: 1 });
    overlay.setAttribute("viewBox", `0 0 ${vp.width} ${vp.height}`);
    const data = (await res.json()) as {
      layer: number;
      xfdf: string;
      revision: string;
    }[];
    layers = data.map((d) => {
      const parsed = parseXfdf(d.xfdf, d.layer, vp);
      return {
        id: d.layer,
        visible: true,
        annotations: parsed.annotations,
        revision: d.revision,
        saved: JSON.stringify(parsed.annotations),
        undo: [],
        redo: [],
        warnings: parsed.warnings,
      };
    });
    ready = true;
    renderAnnotations();
    updateUI();
    fit();
    await renderPdf();
    const warnings = layers.flatMap((l) =>
      l.warnings.map((w) =>
        w.startsWith("Layer ") ? w : `Layer ${l.id}: ${w}`,
      ),
    );
    if (warnings.length) toast([...new Set(warnings)].join(" "), true);
  } catch (e) {
    $("#loading").textContent = (e as Error).message;
    toast((e as Error).message, true);
  }
}
void start();
updateUI();
void fetch("/samples/index.json")
  .then((r) => r.json())
  .then((items: { name: string; url: string }[]) => {
    for (const item of items) {
      const opt = new Option(item.name, item.url);
      $("#sample").append(opt);
    }
  })
  .catch(() => {});
void fetch("/stamps/index.json")
  .then((r) => r.json())
  .then((items: { name: string; url: string }[]) => {
    for (const item of items) {
      const b = document.createElement("button");
      b.className = "stamp-choice";
      b.setAttribute("aria-label", item.name);
      const img = document.createElement("img");
      img.src = item.url;
      img.alt = item.name;
      const label = document.createElement("span");
      label.textContent = item.name;
      b.append(img, label);
      b.onclick = async () => {
        try {
          const r = await fetch(item.url),
            blob = await r.blob();
          const data = await new Promise<string>((resolve) => {
            const reader = new FileReader();
            reader.onload = () => resolve(String(reader.result));
            reader.readAsDataURL(blob);
          });
          await selectImage(data, "stamp");
          $<HTMLDialogElement>("#stamps").close();
        } catch {
          toast("Stamp could not be loaded.", true);
        }
      };
      $("#stamp-grid").append(b);
    }
  })
  .catch(() => {});

new ResizeObserver(() => {
  if (ready && fitting) fit();
}).observe(stage);

$<HTMLInputElement>("#zoom").addEventListener("keydown", (e) => {
  if (e.key === "Enter") {
    e.preventDefault();
    const value = Number($<HTMLInputElement>("#zoom").value);
    if (Number.isFinite(value) && value > 0) setZoom(value / 100);
  }
});

$("#toggle-sidebar").onclick = () => {
  const closed = $(".workspace").classList.toggle("sidebar-closed");
  $("#toggle-sidebar").setAttribute("aria-expanded", String(!closed));
};
if (matchMedia("(max-width: 600px)").matches) {
  $(".workspace").classList.add("sidebar-closed");
  $("#toggle-sidebar").setAttribute("aria-expanded", "false");
}

$("#close-sidebar").onclick = () => $("#toggle-sidebar").click();
let pdfReady = false;
let lastPdfError: string | undefined;
async function refreshPdfStatus() {
  try {
    const response = await fetch("/api/pdf/status");
    if (!response.ok) throw new Error("Status unavailable");
    const state = await response.json();
    pdfReady = state.ready;
    if (state.error && state.error !== lastPdfError)
      recordDiagnostic(`PDF export failed: ${state.error}`);
    lastPdfError = state.error;
    $("#download-pdf").setAttribute("aria-disabled", String(!pdfReady));
    $("#pdf-status").textContent = state.error
      ? `PDF export failed: ${state.error}`
      : state.ready
        ? "All saved layers · vector PDF ready"
        : "Preparing all saved layers in background…";
  } catch {
    pdfReady = false;
    $("#download-pdf").setAttribute("aria-disabled", "true");
    $("#pdf-status").textContent = "Annotated PDF status unavailable";
  }
}
$("#download-pdf").onclick = (event) => {
  if (!pdfReady || layers.some(dirty)) {
    event.preventDefault();
    toast(
      layers.some(dirty)
        ? "Save changed layers before downloading the annotated PDF."
        : "The annotated PDF is still being prepared.",
    );
  }
};
void refreshPdfStatus();
setInterval(() => void refreshPdfStatus(), 2000);
