import { h, svg } from '../core/dom';
import { store } from '../core/store';
import type { Viewer } from './viewer';
import { effectiveRotation, defaultFieldStyle, markThickness, type Anno, type FieldAnno, type PageState, type Pt } from '../core/model';
import { uid, clamp, bufferOf } from '../core/util';
import { captureSignature } from './signature';
import { getLastSignature, setLastSignature, type SignatureData } from '../core/signatures';
import { pickFiles } from '../ui/dropzone';
import { readBytes } from '../core/util';
import { modal } from '../ui/modal';
import { textInput, field } from '../ui/controls';
import { attachAutofill } from '../ui/autofill';
import { cssFamilyFor, ensureFontFor } from '../core/font-loader';

type Handle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';

// Track the most recent pointerdown target so an on-canvas edit that blurs can
// tell whether the blur was caused by clicking a sidebar control (robust across
// browsers, unlike relying on FocusEvent.relatedTarget / button focus quirks).
let lastPointerTarget: EventTarget | null = null;
if (typeof document !== 'undefined') {
  document.addEventListener('pointerdown', (e) => { lastPointerTarget = e.target; }, true);
}
function pointerWentTo(selector: string): boolean {
  const host = document.querySelector(selector);
  return !!(host && lastPointerTarget instanceof Node && host.contains(lastPointerTarget));
}

export class Overlay {
  private pendingImage: { dataUrl: string; w: number; h: number } | null = null;

  constructor(private viewer: Viewer) {
    store.on('annos', () => this.renderAll());
    store.on('selection', () => this.renderAll());
    store.on('zoom', () => this.renderAll());
    store.on('tool', () => this.updateMode());
    store.on('doc', () => this.attach());
  }

  attach() {
    const doc = store.doc;
    if (!doc) return;
    for (const ps of doc.pages.filter((p) => !p.deleted)) {
      const layer = this.viewer.layerFor(ps.id);
      if (layer && !layer.dataset.wired) {
        layer.dataset.wired = '1';
        layer.addEventListener('pointerdown', (e) => this.onLayerPointerDown(e, ps));
      }
    }
    this.viewer.onRelayout = () => this.renderAll();
    store.openSignatureManager = () => this.openSignatureManager();
    this.updateMode();
    this.renderAll();
  }

  private get scale() { return this.viewer.scale; }

  updateMode() {
    const creating = store.tool !== 'select';
    document.querySelector('.workspace')?.classList.toggle('tool-create', creating);
    document.querySelector('.workspace')?.classList.toggle('tool-select', !creating);
  }

  // ---- coordinate conversion (client px → unrotated page points) ----
  private clientToPage(ps: PageState, clientX: number, clientY: number): Pt {
    const layer = this.viewer.layerFor(ps.id)!;
    const rect = layer.getBoundingClientRect();
    const cx = rect.left + rect.width / 2;
    const cy = rect.top + rect.height / 2;
    const dx = clientX - cx;
    const dy = clientY - cy;
    const rad = (-effectiveRotation(ps) * Math.PI) / 180;
    const rx = dx * Math.cos(rad) - dy * Math.sin(rad);
    const ry = dx * Math.sin(rad) + dy * Math.cos(rad);
    const uw = ps.width * this.scale;
    const uh = ps.height * this.scale;
    return { x: (rx + uw / 2) / this.scale, y: (ry + uh / 2) / this.scale };
  }

  // ================= rendering =================
  renderAll() {
    const doc = store.doc;
    if (!doc) return;
    const sel = new Set(store.selection);
    for (const ps of doc.pages.filter((p) => !p.deleted)) {
      const layer = this.viewer.layerFor(ps.id);
      if (!layer) continue;
      layer.replaceChildren();
      for (const a of doc.annos.filter((x) => x.page === ps.id)) {
        layer.appendChild(this.renderAnno(a, sel.has(a.id)));
      }
    }
  }

  private renderAnno(a: Anno, selected: boolean): HTMLElement {
    const s = this.scale;
    const el = h('div.anno anno-' + a.type, {
      dataset: { id: a.id },
      style: {
        left: `${a.x * s}px`, top: `${a.y * s}px`,
        width: `${a.w * s}px`, height: `${a.h * s}px`,
        opacity: a.opacity != null ? String(a.opacity) : '1',
        transform: a.rotation ? `rotate(${a.rotation}deg)` : '',
        transformOrigin: 'center center',
      },
    });
    // flip is applied only to the visual content, so the resize handles keep
    // their logical (unmirrored) positions.
    const flip = (a.flipH || a.flipV) ? `scale(${a.flipH ? -1 : 1}, ${a.flipV ? -1 : 1})` : '';
    const visualWrap = h('div.anno-visual', { style: { position: 'absolute', inset: '0', transform: flip, transformOrigin: 'center center' } });
    visualWrap.appendChild(this.renderVisual(a, s));
    el.appendChild(visualWrap);
    if (selected) {
      el.classList.add('selected');
      if (a.type !== 'note') this.addHandles(el, a);
    }
    el.addEventListener('pointerdown', (e) => this.onAnnoPointerDown(e, a));
    if (a.type === 'text') el.addEventListener('dblclick', () => this.editText(a.id));
    if (a.type === 'note') el.addEventListener('dblclick', () => this.editNote(a.id));
    if (a.type === 'link') el.title = (a as any).url;
    return el;
  }

  private renderVisual(a: Anno, s: number): HTMLElement | SVGElement {
    switch (a.type) {
      case 'text': {
        ensureFontFor(a.fontFamily, a.bold, a.italic); // webfont loads on demand; text reflows when ready
        return h('div.anno-text-inner', {
          style: {
            fontFamily: cssFamilyFor(a.fontFamily), fontSize: `${a.fontSize * s}px`, color: a.color,
            fontWeight: a.bold ? '700' : '400', fontStyle: a.italic ? 'italic' : 'normal',
            textDecoration: a.underline ? 'underline' : 'none',
            textAlign: a.align, background: a.bg || 'transparent', lineHeight: '1.18',
          },
        }, a.text);
      }
      case 'draw': {
        const pts = a.points.map((p) => `${p.x * s},${p.y * s - a.y * s}`).join(' ');
        // draw within an svg sized to the layer-local bbox origin
        const el = svg(`<svg style="position:absolute;left:${-a.x * s}px;top:0;overflow:visible" width="1" height="1"><polyline points="" fill="none" stroke="${a.color}" stroke-width="${a.strokeWidth * s}" stroke-linecap="round" stroke-linejoin="round"/></svg>`);
        (el.firstElementChild as SVGPolylineElement).setAttribute('points', a.points.map((p) => `${p.x * s},${p.y * s}`).join(' '));
        (el as SVGElement).setAttribute('style', `position:absolute;left:${-a.x * s}px;top:${-a.y * s}px;overflow:visible;pointer-events:none`);
        return el;
      }
      case 'shape': {
        if (a.shape === 'rect' || a.shape === 'ellipse') {
          return h('div.anno-shape-box', {
            style: {
              width: '100%', height: '100%', boxSizing: 'border-box',
              border: `${a.strokeWidth * s}px solid ${a.stroke}`,
              borderRadius: a.shape === 'ellipse' ? '50%' : '2px',
              background: a.fill || 'transparent',
            },
          });
        }
        // line / arrow drawn in an overflow-visible svg using signed w/h
        const x1 = 0, y1 = 0, x2 = a.w * s, y2 = a.h * s;
        const head = a.shape === 'arrow' ? arrowHead(x1, y1, x2, y2, a.strokeWidth * s, a.stroke) : '';
        const el = svg(`<svg style="position:absolute;left:0;top:0;overflow:visible;pointer-events:none" width="1" height="1"><line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${a.stroke}" stroke-width="${a.strokeWidth * s}" stroke-linecap="round"/>${head}</svg>`);
        return el;
      }
      case 'highlight':
        return h('div', { style: { width: '100%', height: '100%', background: a.color, opacity: '0.4', mixBlendMode: 'multiply' } });
      case 'underline':
        return h('div', { style: { position: 'absolute', left: '0', right: '0', bottom: '1px', height: `${markThickness(a) * s}px`, background: a.color } });
      case 'strike':
        return h('div', { style: { position: 'absolute', left: '0', right: '0', top: '50%', height: `${markThickness(a) * s}px`, background: a.color } });
      case 'note':
        return h('div.anno-note-icon', { style: { background: a.color } }, noteGlyph());
      case 'image':
      case 'signature':
        return h('img.anno-img', { src: a.dataUrl, draggable: false, style: { width: '100%', height: '100%', objectFit: 'fill' } });
      case 'link':
        return h('div.anno-link-box', { style: { width: '100%', height: '100%' } });
      case 'field':
        return this.renderField(a);
    }
  }

  private renderField(a: Extract<Anno, { type: 'field' }>): HTMLElement {
    const label = a.name || a.field;
    const s = this.scale;
    const style = fieldBoxStyle(a, s);
    const fs = { fontSize: `${(a.fontSize ?? 12) * s}px`, color: a.textColor || '#12141b' };
    if (a.field === 'checkbox') {
      const dot = a.borderColor || '#2f6bff';
      return h('div.anno-field field-check', { style: { ...style } },
        h('span.check-mark', { style: { color: dot, opacity: a.value ? '1' : '0.25' } }, '✓'));
    }
    if (a.field === 'radio') {
      return h('div.anno-field field-radio', { style: { ...style, ...fs } },
        ...(a.options || ['Yes', 'No']).map((o) => h('div.radio-opt', h('span.radio-dot', { style: { borderColor: a.borderColor || '#2f6bff' } }), o)));
    }
    if (a.field === 'dropdown') {
      return h('div.anno-field field-drop', { style: { ...style, ...fs } }, (a.value as string) || (a.options?.[0] ?? 'Select…'), h('span.drop-caret', '▾'));
    }
    return h('div.anno-field field-text', { style: { ...style, ...fs } }, (a.value as string) || label);
  }

  private addHandles(el: HTMLElement, a: Anno) {
    const isLine = a.type === 'shape' && ((a as any).shape === 'line' || (a as any).shape === 'arrow');
    const handles: Handle[] = isLine ? ['nw', 'se'] : ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
    for (const hd of handles) {
      const handle = h('div.anno-handle handle-' + hd, { dataset: { handle: hd } });
      handle.addEventListener('pointerdown', (e) => this.onResizeStart(e, a, hd));
      el.appendChild(handle);
    }
    if (!isLine) {
      const rot = h('div.anno-rotate', { title: 'Rotate (hold Shift to snap)' });
      rot.addEventListener('pointerdown', (e) => this.onRotateStart(e, a));
      el.appendChild(rot);
    }
  }

  private onRotateStart(e: PointerEvent, a: Anno) {
    e.stopPropagation();
    e.preventDefault();
    const el = this.elFor(a.id);
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const cx = rect.left + rect.width / 2;
    const cy = rect.top + rect.height / 2;
    const startAngle = Math.atan2(e.clientY - cy, e.clientX - cx);
    const orig = a.rotation || 0;
    let checkpointed = false;
    const move = (ev: PointerEvent) => {
      const ang = Math.atan2(ev.clientY - cy, ev.clientX - cx);
      let deg = orig + ((ang - startAngle) * 180) / Math.PI;
      if (ev.shiftKey) deg = Math.round(deg / 15) * 15;
      deg = ((deg % 360) + 360) % 360;
      if (!checkpointed) { store.checkpoint(); checkpointed = true; }
      this.liveRotate(a.id, deg);
    };
    const up = () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
      window.removeEventListener('pointercancel', up);
      if (checkpointed) this.commitFromEl(a.id);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
    window.addEventListener('pointercancel', up);
  }

  private liveRotate(id: string, deg: number) {
    const el = this.elFor(id);
    if (!el) return;
    (el as any)._patch = { rotation: deg };
    el.style.transform = deg ? `rotate(${deg}deg)` : '';
  }

  // ================= creation on empty layer =================
  private async onLayerPointerDown(e: PointerEvent, ps: PageState) {
    if (e.button !== 0) return;
    const tool = store.tool;
    if (tool === 'select') {
      // clicking empty space clears selection
      if ((e.target as HTMLElement).classList.contains('anno-layer')) store.clearSelection();
      return;
    }
    e.preventDefault();
    const start = this.clientToPage(ps, e.clientX, e.clientY);

    if (tool === 'text') { this.createText(ps, start); return; }
    if (tool === 'note') { this.createNote(ps, start); return; }
    if (tool === 'image') { await this.createImage(ps, start); return; }
    if (tool === 'signature') { await this.createSignature(ps, start); return; }
    if (tool === 'draw') { this.startDraw(e, ps, start); return; }
    // drag-to-create bbox tools
    this.startDragCreate(e, ps, start, tool);
  }

  private startDraw(e: PointerEvent, ps: PageState, start: Pt) {
    store.checkpoint();
    const points: Pt[] = [start];
    const layer = this.viewer.layerFor(ps.id)!;
    const s = this.scale;
    const el = svg(`<svg class="live-draw" style="position:absolute;inset:0;overflow:visible;pointer-events:none"><polyline fill="none" stroke="${store.toolStyle.color}" stroke-width="${store.toolStyle.strokeWidth * s}" stroke-linecap="round" stroke-linejoin="round"/></svg>`);
    const poly = el.firstElementChild as SVGPolylineElement;
    layer.appendChild(el);
    const move = (ev: PointerEvent) => {
      points.push(this.clientToPage(ps, ev.clientX, ev.clientY));
      poly.setAttribute('points', points.map((p) => `${p.x * s},${p.y * s}`).join(' '));
    };
    const cleanup = () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
      window.removeEventListener('pointercancel', cancel);
      el.remove();
    };
    // the browser reclaimed the gesture (e.g. a scroll took over) — abandon the stroke
    const cancel = () => { cleanup(); };
    const up = () => {
      cleanup();
      if (points.length < 2) return;
      const xs = points.map((p) => p.x), ys = points.map((p) => p.y);
      const minX = Math.min(...xs), minY = Math.min(...ys);
      const a: Anno = {
        id: uid('a_'), page: ps.id, type: 'draw', x: minX, y: minY,
        w: Math.max(...xs) - minX, h: Math.max(...ys) - minY,
        points, color: store.toolStyle.color, strokeWidth: store.toolStyle.strokeWidth,
      };
      store.addAnno(a);
      store.finishCreate(a.id);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
    window.addEventListener('pointercancel', cancel);
  }

  private startDragCreate(e: PointerEvent, ps: PageState, start: Pt, tool: string) {
    store.checkpoint();
    const layer = this.viewer.layerFor(ps.id)!;
    const s = this.scale;
    const ghost = h('div.create-ghost');
    layer.appendChild(ghost);
    let cur = start;
    const paint = () => {
      const x = Math.min(start.x, cur.x), y = Math.min(start.y, cur.y);
      ghost.style.left = `${x * s}px`; ghost.style.top = `${y * s}px`;
      ghost.style.width = `${Math.abs(cur.x - start.x) * s}px`;
      ghost.style.height = `${Math.abs(cur.y - start.y) * s}px`;
    };
    const move = (ev: PointerEvent) => { cur = this.clientToPage(ps, ev.clientX, ev.clientY); paint(); };
    const cleanup = () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
      window.removeEventListener('pointercancel', cancel);
      ghost.remove();
    };
    // gesture reclaimed by the browser — don't commit a half-dragged element
    const cancel = () => { cleanup(); };
    const up = () => {
      cleanup();
      this.commitDragCreate(ps, start, cur, tool);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
    window.addEventListener('pointercancel', cancel);
    paint();
  }

  private commitDragCreate(ps: PageState, start: Pt, end: Pt, tool: string) {
    const ts = store.toolStyle;
    const dx = end.x - start.x, dy = end.y - start.y;
    let x = Math.min(start.x, end.x), y = Math.min(start.y, end.y);
    let w = Math.abs(dx), h = Math.abs(dy);
    const tiny = w < 4 && h < 4;
    let a: Anno | null = null;
    const base = { id: uid('a_'), page: ps.id, x, y, w, h };

    if (tool === 'rect' || tool === 'ellipse') {
      if (tiny) { w = 120; h = 70; }
      a = { ...base, w, h, type: 'shape', shape: tool as any, stroke: ts.color, strokeWidth: ts.strokeWidth, fill: null };
    } else if (tool === 'line' || tool === 'arrow') {
      // store start point + signed deltas
      a = { id: uid('a_'), page: ps.id, x: start.x, y: start.y, w: dx || 60, h: dy, type: 'shape', shape: tool as any, stroke: ts.color, strokeWidth: ts.strokeWidth };
    } else if (tool === 'highlight') {
      if (tiny) { w = 160; h = 18; }
      a = { ...base, w, h, type: 'highlight', color: ts.highlight };
    } else if (tool === 'underline' || tool === 'strike') {
      if (tiny) { w = 160; h = 18; }
      a = { ...base, w, h, type: tool as any, color: ts.color, strokeWidth: ts.strokeWidth };
    } else if (tool === 'link') {
      if (tiny) { w = 160; h = 24; }
      this.createLink(ps, { ...base, w, h });
      return;
    } else if (tool.startsWith('field-')) {
      const kind = tool.split('-')[1] as any;
      if (tiny) { w = kind === 'checkbox' ? 22 : 160; h = kind === 'radio' ? 60 : 26; }
      a = {
        ...base, w, h, type: 'field', field: kind, name: kind + '_field',
        value: kind === 'checkbox' ? false : '', options: kind === 'dropdown' ? ['Option 1', 'Option 2'] : kind === 'radio' ? ['Yes', 'No'] : undefined,
        ...defaultFieldStyle(),
      };
    }
    if (a) { store.addAnno(a); store.finishCreate(a.id); }
  }

  private createText(ps: PageState, at: Pt) {
    store.checkpoint();
    const ts = store.toolStyle;
    const a: Anno = {
      id: uid('a_'), page: ps.id, type: 'text', x: at.x, y: at.y, w: 200, h: ts.fontSize * 1.6,
      text: '', fontFamily: ts.fontFamily, fontSize: ts.fontSize, color: ts.color, align: 'left', bg: null,
      bold: ts.bold, italic: ts.italic, underline: ts.underline,
    };
    store.addAnno(a);
    store.select([a.id]);
    requestAnimationFrame(() => this.editText(a.id, true));
  }

  private editText(id: string, isNew = false) {
    const a = store.doc!.annos.find((x) => x.id === id) as any;
    if (!a || a.type !== 'text') return;
    const layer = this.viewer.layerFor(a.page)!;
    const el = layer.querySelector(`.anno[data-id="${id}"] .anno-text-inner`) as HTMLElement;
    if (!el) return;
    el.contentEditable = 'true';
    el.classList.add('editing');
    el.focus();
    if (isNew) el.textContent = '';
    // place caret at end
    const range = document.createRange();
    range.selectNodeContents(el);
    range.collapse(false);
    const selr = window.getSelection()!;
    selr.removeAllRanges();
    selr.addRange(range);
    // soft-fill: attach before the Escape handler below so a shown suggestion
    // consumes Tab/Enter first. Skip on rotated boxes (the caret rect the ghost
    // pins to is axis-aligned, so it'd sit wrong).
    const detachAF = attachAutofill(el, { enabled: () => store.autofillEnabled && !a.rotation });
    const commit = () => {
      detachAF();
      el.contentEditable = 'false';
      el.classList.remove('editing');
      const text = el.innerText.replace(/\n$/, '');
      const s = this.scale;
      const newH = Math.max(a.fontSize * 1.4, el.scrollHeight / s);
      // if the blur was caused by clicking a sidebar control, keep the annotation
      // (don't treat it as abandoning an empty new text — the user is about to edit
      // its value or styles from the panel).
      const toPanel = pointerWentTo('.properties-panel');
      if (!text.trim() && isNew && !toPanel) { store.removeAnnos([id]); return; }
      // commit silently so a click on a panel control (flip/rotate/font/…) that
      // triggered this blur isn't consumed by the panel rebuilding underneath it.
      store.silent = true;
      try {
        store.updateAnno(id, { text, h: newH } as any);
        if (isNew && store.autoCursor && store.tool === 'text') store.setTool('select');
      } finally {
        store.silent = false;
      }
      // refresh the (intentionally un-rebuilt) panel next frame — after any click
      // that triggered this blur is handled — unless the user is now typing in a
      // panel field (don't rebuild the control under them).
      requestAnimationFrame(() => {
        const panel = document.querySelector('.properties-panel');
        if (panel && panel.contains(document.activeElement)) return;
        store.refreshPanel?.();
      });
    };
    el.addEventListener('blur', commit, { once: true });
    el.addEventListener('keydown', (e) => {
      if ((e as KeyboardEvent).key === 'Escape') { (e.target as HTMLElement).blur(); }
    });
  }

  private createNote(ps: PageState, at: Pt) {
    store.checkpoint();
    const a: Anno = { id: uid('a_'), page: ps.id, type: 'note', x: at.x, y: at.y, w: 22, h: 22, text: '', color: store.toolStyle.highlight };
    store.addAnno(a);
    store.finishCreate(a.id);
    this.editNote(a.id);
  }
  private editNote(id: string) {
    const a = store.doc!.annos.find((x) => x.id === id) as any;
    if (!a) return;
    const m = modal({ title: 'Sticky note', width: 420 });
    const ta = h('textarea.input', { rows: 4, value: a.text }) as HTMLTextAreaElement;
    m.body.appendChild(field('Note', ta));
    m.footer.append(
      h('button.btn btn-soft', { onclick: () => m.close() }, 'Cancel'),
      h('button.btn btn-primary', { onclick: () => { store.checkpoint(); store.updateAnno(id, { text: ta.value } as any); m.close(); } }, 'Save'),
    );
    setTimeout(() => ta.focus(), 60);
  }

  private async createImage(ps: PageState, at: Pt) {
    const files = await pickFiles('image/*', false);
    if (!files.length) return;
    const bytes = await readBytes(files[0]);
    const isPng = files[0].type.includes('png');
    const dataUrl = await bytesToPngDataUrl(bytes, files[0].type);
    const dims = await imageDims(dataUrl);
    store.checkpoint();
    const maxW = 260;
    const scaleI = Math.min(1, maxW / dims.w);
    const a: Anno = { id: uid('a_'), page: ps.id, type: 'image', x: at.x, y: at.y, w: dims.w * scaleI, h: dims.h * scaleI, dataUrl, naturalW: dims.w, naturalH: dims.h };
    store.addAnno(a);
    store.select([a.id]);
    store.setTool('select'); // images are one-shot (file picker) — always return to select
  }

  private async createSignature(ps: PageState, at: Pt) {
    let sig: SignatureData | null = null;
    // "reuse last signature" places the most recent one without opening the picker
    if (store.reuseSignature) sig = await getLastSignature();
    if (!sig) sig = await captureSignature();
    if (!sig) return;
    await setLastSignature(sig);
    await this.placeSignature(ps, at, sig);
  }

  private async placeSignature(ps: PageState, at: Pt, sig: SignatureData) {
    const dims = await imageDims(sig.dataUrl);
    store.checkpoint();
    const w = 200;
    const a: Anno = { id: uid('a_'), page: ps.id, type: 'signature', x: at.x, y: at.y, w, h: (w * dims.h) / dims.w, dataUrl: sig.dataUrl, naturalW: dims.w, naturalH: dims.h };
    store.addAnno(a);
    store.select([a.id]);
    store.setTool('select');
  }

  /** Open the signature picker from a button (not a canvas click) and drop it on
   *  the current page. Also used to add/manage saved signatures. */
  async openSignatureManager() {
    const sig = await captureSignature();
    if (!sig) return;
    await setLastSignature(sig);
    const live = store.doc!.pages.filter((p) => !p.deleted);
    const ps = live[store.currentPage] ?? live[0];
    if (!ps) return;
    const at: Pt = { x: Math.max(20, ps.width * 0.15), y: Math.max(20, ps.height * 0.4) };
    await this.placeSignature(ps, at, sig);
    this.viewer.scrollToPage(ps.id);
  }

  private createLink(ps: PageState, box: { id: string; page: string; x: number; y: number; w: number; h: number }) {
    const m = modal({ title: 'Add a link', width: 440 });
    const inp = textInput({ placeholder: 'https://example.com' });
    m.body.appendChild(field('Link URL', inp, 'The rectangle you drew becomes a clickable link.'));
    m.footer.append(
      h('button.btn btn-soft', { onclick: () => m.close() }, 'Cancel'),
      h('button.btn btn-primary', { onclick: () => {
        let url = inp.value.trim();
        if (!url) { m.close(); return; }
        if (!/^https?:\/\//i.test(url) && !url.startsWith('mailto:')) url = 'https://' + url;
        store.checkpoint();
        store.addAnno({ ...box, type: 'link', url } as Anno);
        store.finishCreate(box.id);
        m.close();
      } }, 'Add link'),
    );
    setTimeout(() => inp.focus(), 60);
  }

  // ================= move / resize existing =================
  private onAnnoPointerDown(e: PointerEvent, a: Anno) {
    if (store.tool !== 'select') return;
    if ((e.target as HTMLElement).classList.contains('anno-handle')) return;
    if ((e.target as HTMLElement).isContentEditable) return;
    e.stopPropagation();
    const additive = e.shiftKey;
    if (!store.selection.includes(a.id)) store.toggleSelect(a.id, additive);
    if (a.locked) return;

    const ps = store.doc!.pages.find((p) => p.id === a.page)!;
    const startPage = this.clientToPage(ps, e.clientX, e.clientY);
    const originals = store.selectedAnnos.map((x) => ({ id: x.id, x: x.x, y: x.y, points: (x as any).points }));
    let moved = false;
    const move = (ev: PointerEvent) => {
      const now = this.clientToPage(ps, ev.clientX, ev.clientY);
      const ddx = now.x - startPage.x, ddy = now.y - startPage.y;
      if (!moved && Math.hypot(ddx, ddy) > 1.5) { store.checkpoint(); moved = true; }
      for (const o of originals) {
        const patch: any = { x: o.x + ddx, y: o.y + ddy };
        if (o.points) patch.points = o.points.map((p: Pt) => ({ x: p.x + ddx, y: p.y + ddy }));
        this.liveMove(o.id, patch);
      }
    };
    const up = () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
      window.removeEventListener('pointercancel', up);
      if (moved) for (const o of originals) { const el = this.elFor(o.id); if (el) this.commitFromEl(o.id); }
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
    window.addEventListener('pointercancel', up);
  }

  private onResizeStart(e: PointerEvent, a: Anno, handle: Handle) {
    e.stopPropagation();
    e.preventDefault();
    const ps = store.doc!.pages.find((p) => p.id === a.page)!;
    const orig = { x: a.x, y: a.y, w: a.w, h: a.h, points: (a as any).points as Pt[] | undefined };
    const startPage = this.clientToPage(ps, e.clientX, e.clientY);
    let checkpointed = false;
    const isLine = a.type === 'shape' && ((a as any).shape === 'line' || (a as any).shape === 'arrow');
    const move = (ev: PointerEvent) => {
      const now = this.clientToPage(ps, ev.clientX, ev.clientY);
      const ddx = now.x - startPage.x, ddy = now.y - startPage.y;
      if (!checkpointed) { store.checkpoint(); checkpointed = true; }
      if (isLine) {
        // nw = start point, se = end point (w/h are signed deltas)
        const p: any = {};
        if (handle === 'nw') { p.x = orig.x + ddx; p.y = orig.y + ddy; p.w = orig.w - ddx; p.h = orig.h - ddy; }
        else { p.w = orig.w + ddx; p.h = orig.h + ddy; }
        this.liveMove(a.id, p);
        return;
      }
      // transform the pointer delta into the element's own (unrotated) frame
      const r = (a.rotation || 0) * Math.PI / 180;
      const cos = Math.cos(r), sin = Math.sin(r);
      const ldx = ddx * cos + ddy * sin;
      const ldy = -ddx * sin + ddy * cos;

      const uniform = store.uniformScale !== ev.shiftKey;
      const growX = handle.includes('e') ? ldx : handle.includes('w') ? -ldx : 0;
      const growY = handle.includes('s') ? ldy : handle.includes('n') ? -ldy : 0;
      const hasH = handle.includes('e') || handle.includes('w');
      const hasV = handle.includes('n') || handle.includes('s');
      let w: number, h: number;
      if (uniform && orig.w > 0 && orig.h > 0) {
        let sc: number;
        if (hasH && hasV) sc = Math.abs(growX) > Math.abs(growY) ? (orig.w + growX) / orig.w : (orig.h + growY) / orig.h;
        else if (hasH) sc = (orig.w + growX) / orig.w;
        else sc = (orig.h + growY) / orig.h;
        sc = Math.max(sc, 6 / orig.w, 6 / orig.h);
        w = orig.w * sc; h = orig.h * sc;
      } else {
        w = Math.max(6, orig.w + growX);
        h = Math.max(6, orig.h + growY);
      }

      // keep the opposite corner/edge fixed in page space (works at any rotation)
      const aSignX = handle.includes('e') ? -1 : handle.includes('w') ? 1 : 0;
      const aSignY = handle.includes('s') ? -1 : handle.includes('n') ? 1 : 0;
      const c0x = orig.x + orig.w / 2, c0y = orig.y + orig.h / 2;
      const alx = (aSignX * orig.w) / 2, aly = (aSignY * orig.h) / 2;
      const anchorWx = c0x + (alx * cos - aly * sin);
      const anchorWy = c0y + (alx * sin + aly * cos);
      const nlx = (aSignX * w) / 2, nly = (aSignY * h) / 2;
      const ncx = anchorWx - (nlx * cos - nly * sin);
      const ncy = anchorWy - (nlx * sin + nly * cos);
      const x = ncx - w / 2, y = ncy - h / 2;

      const patch: any = { x, y, w, h };
      if (orig.points && orig.w > 0 && orig.h > 0) {
        const sx = w / orig.w, sy = h / orig.h;
        patch.points = orig.points.map((p) => ({ x: x + (p.x - orig.x) * sx, y: y + (p.y - orig.y) * sy }));
      }
      this.liveMove(a.id, patch);
    };
    const up = () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
      window.removeEventListener('pointercancel', up);
      if (checkpointed) this.commitFromEl(a.id);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
    window.addEventListener('pointercancel', up);
  }

  private elFor(id: string): HTMLElement | null {
    const a = store.doc!.annos.find((x) => x.id === id);
    if (!a) return null;
    return this.viewer.layerFor(a.page)?.querySelector(`.anno[data-id="${id}"]`) ?? null;
  }

  /** Update the DOM element live during a drag (no store churn), stash patch on the element. */
  private liveMove(id: string, patch: any) {
    const el = this.elFor(id);
    if (!el) return;
    (el as any)._patch = patch;
    const s = this.scale;
    if (patch.x != null) el.style.left = `${patch.x * s}px`;
    if (patch.y != null) el.style.top = `${patch.y * s}px`;
    if (patch.w != null) el.style.width = `${patch.w * s}px`;
    if (patch.h != null) el.style.height = `${patch.h * s}px`;
  }
  private commitFromEl(id: string) {
    const el = this.elFor(id);
    if (!el) return;
    const patch = (el as any)._patch;
    if (patch) store.updateAnno(id, patch);
  }

  setPendingImage(_: any) { /* reserved */ }
}

// ---------- helpers ----------
function fieldBoxStyle(a: FieldAnno, s: number): Record<string, string> {
  const bw = Math.max(1, a.borderWidth ?? 1) * s;
  const border = a.borderColor || '#2f6bff';
  const bg = a.bgColor;
  const base: Record<string, string> = { width: '100%', height: '100%', boxSizing: 'border-box' };
  switch (a.fieldStyle || 'box') {
    case 'filled': return { ...base, background: bg || '#eef1f7', border: 'none' };
    case 'underline': return { ...base, background: 'transparent', border: 'none', borderBottom: `${bw}px solid ${border}`, borderRadius: '0' };
    case 'minimal': return { ...base, background: 'transparent', border: `1px dashed color-mix(in srgb, ${border} 55%, transparent)` };
    default: return { ...base, background: bg || 'transparent', border: `${bw}px solid ${border}` };
  }
}
function arrowHead(x1: number, y1: number, x2: number, y2: number, sw: number, color: string): string {
  const ang = Math.atan2(y2 - y1, x2 - x1);
  const len = Math.max(8, sw * 3.2);
  const a1 = ang + Math.PI - 0.5, a2 = ang + Math.PI + 0.5;
  const hx1 = x2 + len * Math.cos(a1), hy1 = y2 + len * Math.sin(a1);
  const hx2 = x2 + len * Math.cos(a2), hy2 = y2 + len * Math.sin(a2);
  return `<line x1="${x2}" y1="${y2}" x2="${hx1}" y2="${hy1}" stroke="${color}" stroke-width="${sw}" stroke-linecap="round"/><line x1="${x2}" y1="${y2}" x2="${hx2}" y2="${hy2}" stroke="${color}" stroke-width="${sw}" stroke-linecap="round"/>`;
}
function noteGlyph(): SVGElement {
  return svg('<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="#5a4a00" stroke-width="2"><path d="M6 8h12M6 12h8"/></svg>');
}
async function imageDims(dataUrl: string): Promise<{ w: number; h: number }> {
  return new Promise((res) => {
    const img = new Image();
    img.onload = () => res({ w: img.naturalWidth, h: img.naturalHeight });
    img.src = dataUrl;
  });
}
async function bytesToPngDataUrl(bytes: Uint8Array, type: string): Promise<string> {
  const blob = new Blob([bufferOf(bytes)], { type });
  const url = URL.createObjectURL(blob);
  const dims = await imageDims(url);
  const c = document.createElement('canvas');
  c.width = dims.w; c.height = dims.h;
  const img = new Image();
  await new Promise<void>((r) => { img.onload = () => r(); img.src = url; });
  c.getContext('2d')!.drawImage(img, 0, 0);
  URL.revokeObjectURL(url);
  // keep jpg as jpg for size; else png
  return type.includes('jpeg') || type.includes('jpg') ? c.toDataURL('image/jpeg', 0.92) : c.toDataURL('image/png');
}
