import { h } from '../core/dom';
import { store } from '../core/store';
import type { Viewer } from './viewer';
import { effectiveRotation, type PageState } from '../core/model';
import { icon } from '../ui/icons';
import { toast } from '../ui/toast';
import { queryPage, redactAreas, editTextSpan, editImage, deleteImage } from '../core/mupdf';
import type { PageContent, TextSpanInfo, ImageObjInfo } from '../core/mupdf-types';
import { resolveDocFont, variantFor, FONT_FAMILIES } from '../core/fonts';
import { ensureFontLoaded, cssFamilyFor } from '../core/font-loader';

/** In-session font/style overrides while editing a native text span. */
type EditStyle = { id: string; bold: boolean; italic: boolean; underline: boolean };

type Mode = 'off' | 'edit-text' | 'edit-image' | 'redact';
type Box = { x: number; y: number; w: number; h: number };
type ImgState = Box & { rotation: number; flipH: boolean; flipV: boolean };
type RedactMark = { pageId: string; srcIndex: number } & Box;

/**
 * Drives the three native (mupdf) content tools over each page's `.edit-layer`:
 *  - redact:     draw rectangles → applyRedactions (true content removal)
 *  - edit-text:  click a real text span → retype it (redact + redraw)
 *  - edit-image: select a real image → move/scale/rotate/flip (redact + redraw)
 *
 * Only one mode is active at a time (tools are mutually exclusive), so a single
 * owner of the edit-layer keeps coordination simple. Every commit produces new
 * PDF bytes which flow through `applyEdit` (the editor's bytes-swap pipeline).
 *
 * Geometry note: content editing runs in each page's UNROTATED space, which
 * equals mupdf device space for a page with /Rotate 0 — the common case. Pages
 * with a non-zero rotation show a hint instead of (mis-aligned) boxes.
 */
export class ContentEditor {
  private mode: Mode = 'off';
  private cache = new Map<string, PageContent>(); // pageId → mupdf content
  private redactMarks: RedactMark[] = [];
  private actionBar: HTMLElement | null = null;
  private busy = false;

  constructor(
    private viewer: Viewer,
    private applyEdit: (bytes: Uint8Array, changedPages?: number[]) => Promise<void>,
  ) {
    store.on('tool', () => this.syncMode());
    // defer to the next frame so the viewer has resized the edit-layers first
    store.on('zoom', () => requestAnimationFrame(() => this.render()));
    store.on('page', () => this.render());
    store.on('doc', () => { this.cache.clear(); this.redactMarks = []; this.attach(); });
  }

  /** Wire each page's edit-layer once (called after viewer.build()). */
  attach() {
    const doc = store.doc;
    if (!doc) return;
    for (const ps of doc.pages.filter((p) => !p.deleted)) {
      const layer = this.viewer.editLayerFor(ps.id);
      if (layer && !layer.dataset.wired) {
        layer.dataset.wired = '1';
        layer.addEventListener('pointerdown', (e) => this.onLayerPointerDown(e, ps));
      }
    }
    this.syncMode();
  }

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

  private syncMode() {
    const next: Mode =
      store.tool === 'redact' ? 'redact'
      : store.tool === 'edit-text' ? 'edit-text'
      : store.tool === 'edit-image' ? 'edit-image'
      : 'off';
    if (next === this.mode) return;
    this.mode = next;
    const ws = document.querySelector('.workspace');
    ws?.classList.toggle('content-mode', next !== 'off');
    this.clearLayers();
    if (next === 'off') {
      this.hideActionBar();
    } else {
      // fresh content read whenever we (re)enter an editing tool
      if (next !== 'redact') this.cache.clear();
      this.render();
      if (next === 'redact') this.showRedactHint();
    }
  }

  // ---------- coordinate + layer helpers ----------

  private livePages(): PageState[] {
    return store.doc ? store.doc.pages.filter((p) => !p.deleted) : [];
  }
  private editablePage(ps: PageState): boolean {
    return !ps.blank && ps.srcIndex >= 0 && effectiveRotation(ps) === 0;
  }
  /** client px → unrotated page points (valid for /Rotate 0 pages). */
  private clientToModel(ps: PageState, clientX: number, clientY: number): { x: number; y: number } {
    const layer = this.viewer.editLayerFor(ps.id)!;
    const r = layer.getBoundingClientRect();
    return { x: (clientX - r.left) / this.scale, y: (clientY - r.top) / this.scale };
  }
  private clearLayers() {
    for (const ps of this.livePages()) {
      const layer = this.viewer.editLayerFor(ps.id);
      if (layer) layer.replaceChildren();
    }
  }

  private async getContent(ps: PageState): Promise<PageContent | null> {
    if (!this.editablePage(ps)) return null;
    if (this.cache.has(ps.id)) return this.cache.get(ps.id)!;
    try {
      const pc = await queryPage(store.doc!.bytes, ps.srcIndex);
      this.cache.set(ps.id, pc);
      return pc;
    } catch (err) {
      console.error('mupdf query failed', err);
      return null;
    }
  }

  // ---------- top-level render ----------

  private render() {
    if (this.mode === 'off') return;
    this.clearLayers();
    if (this.mode === 'redact') {
      this.renderRedactMarks();
      this.updateActionBar();
      return;
    }
    // edit-text / edit-image: render boxes for a small window around the
    // current page to keep worker queries bounded on large documents
    const pages = this.livePages();
    const cur = store.currentPage;
    for (let i = Math.max(0, cur - 1); i <= Math.min(pages.length - 1, cur + 1); i++) {
      const ps = pages[i];
      const layer = this.viewer.editLayerFor(ps.id);
      if (!layer) continue;
      if (!this.editablePage(ps)) { this.renderRotatedHint(ps); continue; }
      void this.renderPageObjects(ps);
    }
  }

  private renderRotatedHint(ps: PageState) {
    if (effectiveRotation(ps) === 0) return;
    const layer = this.viewer.editLayerFor(ps.id);
    if (!layer) return;
    layer.appendChild(h('div.ce-hint', 'Reset this page to 0° rotation to edit its content.'));
  }

  private async renderPageObjects(ps: PageState) {
    const pc = await this.getContent(ps);
    const layer = this.viewer.editLayerFor(ps.id);
    if (!pc || !layer || this.mode === 'off') return;
    // page may have scrolled out of the window while awaiting; re-check tool
    if ((this.mode === 'edit-text' && store.tool !== 'edit-text') ||
        (this.mode === 'edit-image' && store.tool !== 'edit-image')) return;
    layer.replaceChildren();
    if (this.mode === 'edit-text') {
      for (const span of pc.spans) layer.appendChild(this.textBox(ps, span));
    } else if (this.mode === 'edit-image') {
      for (const img of pc.images) layer.appendChild(this.imageBox(ps, img));
    }
  }

  // ================= REDACT =================

  private onLayerPointerDown(e: PointerEvent, ps: PageState) {
    if (e.button !== 0 || this.busy) return;
    if (this.mode === 'redact') { this.startRedactDrag(e, ps); return; }
    // edit-text / edit-image: clicking empty space is a no-op (boxes handle their own events)
  }

  private startRedactDrag(e: PointerEvent, ps: PageState) {
    if (!this.editablePage(ps)) { toast('This page can’t be redacted (blank or rotated).', { type: 'error' }); return; }
    e.preventDefault();
    const layer = this.viewer.editLayerFor(ps.id)!;
    const start = this.clientToModel(ps, e.clientX, e.clientY);
    const s = this.scale;
    const ghost = h('div.ce-redact-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.clientToModel(ps, ev.clientX, ev.clientY); paint(); };
    const done = () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', done);
      window.removeEventListener('pointercancel', cancel);
      ghost.remove();
      const x = Math.min(start.x, cur.x), y = Math.min(start.y, cur.y);
      const w = Math.abs(cur.x - start.x), hh = Math.abs(cur.y - start.y);
      if (w > 3 && hh > 3) {
        this.redactMarks.push({ pageId: ps.id, srcIndex: ps.srcIndex, x, y, w, h: hh });
        this.render();
      }
    };
    const cancel = () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', done);
      window.removeEventListener('pointercancel', cancel);
      ghost.remove();
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', done);
    window.addEventListener('pointercancel', cancel);
    paint();
  }

  private renderRedactMarks() {
    const s = this.scale;
    for (const ps of this.livePages()) {
      const layer = this.viewer.editLayerFor(ps.id);
      if (!layer) continue;
      for (const m of this.redactMarks.filter((mk) => mk.pageId === ps.id)) {
        const box = h('div.ce-redact-mark', {
          style: { left: `${m.x * s}px`, top: `${m.y * s}px`, width: `${m.w * s}px`, height: `${m.h * s}px` },
        });
        const del = h('button.ce-mark-del', { title: 'Remove', onclick: (ev: Event) => { ev.stopPropagation(); this.removeMark(m); } }, icon('x', 12));
        box.appendChild(del);
        layer.appendChild(box);
      }
    }
  }
  private removeMark(m: RedactMark) {
    this.redactMarks = this.redactMarks.filter((x) => x !== m);
    this.render();
  }

  private showRedactHint() {
    // top position: the bottom-anchored toast host overlaps the redact action
    // bar's "Apply redactions" button (also bottom-anchored), hiding it.
    if (!this.redactMarks.length) toast('Drag to mark areas, then Apply — redaction permanently deletes the underlying text and images.', { type: 'info', duration: 5000, position: 'top' });
  }

  private ensureActionBar(): HTMLElement {
    if (!this.actionBar) {
      this.actionBar = h('div.ce-action-bar');
      document.querySelector('.workspace')?.appendChild(this.actionBar);
    }
    return this.actionBar;
  }
  private hideActionBar() { this.actionBar?.remove(); this.actionBar = null; }

  private updateActionBar() {
    if (this.mode !== 'redact' || !this.redactMarks.length) { this.hideActionBar(); return; }
    const bar = this.ensureActionBar();
    bar.replaceChildren(
      h('span.ce-action-label', `${this.redactMarks.length} area${this.redactMarks.length > 1 ? 's' : ''} marked`),
      h('button.btn btn-soft', { onclick: () => { this.redactMarks = []; this.render(); } }, 'Clear'),
      h('button.btn btn-danger', { onclick: () => this.applyRedactions() }, icon('redact', 15), 'Apply redactions'),
    );
  }

  private async applyRedactions() {
    if (this.busy || !this.redactMarks.length || !store.doc) return;
    this.busy = true;
    const t = toast('Redacting…', { type: 'progress' });
    try {
      const marks = this.redactMarks.map((m) => ({
        pageIndex: m.srcIndex,
        rect: [m.x, m.y, m.x + m.w, m.y + m.h] as [number, number, number, number],
      }));
      const changed = [...new Set(marks.map((m) => m.pageIndex))];
      const out = await redactAreas(store.doc.bytes, marks, 'pixels');
      this.redactMarks = [];
      this.cache.clear();
      await this.applyEdit(out, changed);
      t.close();
      toast('Redaction applied — the content is gone for good.', { type: 'success' });
      this.render();
    } catch (err) {
      t.close();
      console.error(err);
      toast('Redaction failed. See the console for details.', { type: 'error' });
    } finally {
      this.busy = false;
    }
  }

  // ================= EDIT TEXT =================

  private textBox(ps: PageState, span: TextSpanInfo): HTMLElement {
    const s = this.scale;
    const [x0, y0, x1, y1] = span.bbox;
    const box = h(`div.ce-text-box${span.invisible ? ' ce-text-box-scan' : ''}`, {
      title: span.invisible ? 'Scanned text (OCR layer) — editing covers the original with a plain box' : 'Click to edit this text',
      style: { left: `${x0 * s}px`, top: `${y0 * s}px`, width: `${(x1 - x0) * s}px`, height: `${(y1 - y0) * s}px` },
    });
    box.addEventListener('pointerdown', (e) => {
      e.preventDefault(); e.stopPropagation();
      this.openInlineEditor(ps, span, box);
    });
    return box;
  }

  private openInlineEditor(ps: PageState, span: TextSpanInfo, box: HTMLElement) {
    if (this.busy) return;
    const s = this.scale;
    const [x0, y0, x1, y1] = span.bbox;
    const layer = this.viewer.editLayerFor(ps.id)!;
    // Match the document's font with a metric-compatible substitute, preselected;
    // the user can override it (and toggle bold/italic/underline) in the toolbar.
    const resolved = resolveDocFont(span.fontName, span);
    const style: EditStyle = { id: resolved.id, bold: span.bold, italic: span.italic, underline: false };
    void ensureFontLoaded(style.id, resolved.variant);
    if (span.invisible) toast('This is scanned/OCR text — your edit will cover the original with a plain box, not blend into the scan.', { type: 'info' });

    const input = h('textarea.ce-text-input', {
      style: {
        left: `${x0 * s}px`, top: `${y0 * s}px`,
        width: `${Math.max(40, (x1 - x0) * s + 8)}px`, height: `${Math.max(span.size * s * 1.2, (y1 - y0) * s)}px`,
        fontSize: `${span.size * s}px`,
        color: span.color,
      },
    }) as HTMLTextAreaElement;
    const applyFont = () => {
      input.style.fontFamily = cssFamilyFor(style.id);
      input.style.fontWeight = style.bold ? '700' : '400';
      input.style.fontStyle = style.italic ? 'italic' : 'normal';
      input.style.textDecoration = style.underline ? 'underline' : 'none';
      void ensureFontLoaded(style.id, variantFor(style.bold, style.italic));
    };
    applyFont();
    input.value = span.text;

    // compact style toolbar above the editor: font family + B / I / U
    const fontSel = h('select.ce-font-sel', {
      onchange: () => { style.id = fontSel.value; applyFont(); input.focus(); },
    }, ...FONT_FAMILIES.map((f) => h('option', { value: f.id, selected: f.id === style.id }, f.label))) as HTMLSelectElement;
    const mkToggle = (label: string, cls: string, get: () => boolean, set: (v: boolean) => void) => {
      const b = h('button.ce-style-btn ' + cls + (get() ? ' on' : ''), {
        // keep the textarea focused so toggling never blur-commits the edit
        onpointerdown: (e: Event) => e.preventDefault(),
        onclick: () => { set(!get()); b.classList.toggle('on', get()); applyFont(); input.focus(); },
      }, label);
      return b;
    };
    const bar = h('div.ce-edit-bar', { style: { left: `${x0 * s}px`, top: `${Math.max(0, y0 * s - 38)}px` } },
      fontSel,
      mkToggle('B', 'ce-b', () => style.bold, (v) => (style.bold = v)),
      mkToggle('I', 'ce-i', () => style.italic, (v) => (style.italic = v)),
      mkToggle('U', 'ce-u', () => style.underline, (v) => (style.underline = v)),
    );
    layer.appendChild(bar);

    box.replaceWith(input);
    input.focus();
    input.select();

    // Tracks whether the most recent pointerdown anywhere landed inside the
    // editor/toolbar. Opening the native <select> steals DOM focus from `input`
    // (unlike the B/I/U buttons, its click can't be preventDefault()-ed without
    // also blocking the dropdown from opening), which fires `blur` on some
    // browsers before `document.activeElement` has settled on the select — a
    // race that made the resulting blur look like a click-away and close the
    // whole editor. Track it explicitly instead of trusting activeElement's timing.
    let lastDownInBar = false;
    let committed = false;
    const cleanup = () => { input.remove(); bar.remove(); document.removeEventListener('pointerdown', onDocDown, true); };
    const finish = async (commit: boolean) => {
      if (committed) return;
      committed = true;
      const newText = input.value;
      cleanup();
      const changed = newText !== span.text || style.id !== resolved.id
        || style.bold !== span.bold || style.italic !== span.italic || style.underline;
      if (!commit || !changed) { this.render(); return; }
      await this.commitTextEdit(ps, span, newText, style);
    };
    // Commit on a click anywhere outside the editor + its toolbar. (Added after
    // the triggering pointerdown already passed document's capture phase, so it
    // doesn't fire for the very click that opened the editor.)
    const onDocDown = (e: PointerEvent) => {
      const t = e.target as Node;
      lastDownInBar = input.contains(t) || bar.contains(t) || t === input;
      if (lastDownInBar) return;
      finish(true);
    };
    document.addEventListener('pointerdown', onDocDown, true);
    input.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') { e.preventDefault(); finish(false); }
      // Enter commits, Shift+Enter inserts a newline
      else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); finish(true); }
    });
    input.addEventListener('blur', () => {
      // don't commit if focus moved into our own toolbar (e.g. the font select)
      // — or the blur was merely a side effect of opening it (see lastDownInBar above)
      if (lastDownInBar || bar.contains(document.activeElement)) return;
      finish(true);
    });
  }

  private async commitTextEdit(ps: PageState, span: TextSpanInfo, newText: string, style: EditStyle) {
    if (!store.doc) return;
    this.busy = true;
    const t = toast('Updating text…', { type: 'progress' });
    try {
      // Only try reusing the span's own source font when the user left the
      // toolbar at the document's original family/weight/style — an explicit
      // override always draws with the requested substitute instead.
      const resolved = resolveDocFont(span.fontName, span);
      const reuseOriginalFont = style.id === resolved.id && style.bold === span.bold && style.italic === span.italic;
      const out = await editTextSpan(store.doc.bytes, {
        pageIndex: ps.srcIndex,
        spanIndex: span.index,
        bbox: span.bbox,
        origin: span.origin,
        text: newText,
        size: span.size,
        color: span.color,
        serif: span.serif, mono: span.mono, bold: style.bold, italic: style.italic,
        fontFamily: style.id, variant: variantFor(style.bold, style.italic), underline: style.underline,
        reuseOriginalFont,
        invisible: span.invisible,
      });
      this.cache.clear();
      await this.applyEdit(out, [ps.srcIndex]);
      t.close();
      toast('Text updated.', { type: 'success' });
    } catch (err) {
      t.close();
      console.error(err);
      toast('Couldn’t edit that text. See the console for details.', { type: 'error' });
    } finally {
      this.busy = false;
      this.render();
    }
  }

  // ================= EDIT IMAGE =================

  private imageBox(ps: PageState, img: ImageObjInfo): HTMLElement {
    const s = this.scale;
    const [x0, y0, x1, y1] = img.bbox;
    const st: ImgState = { x: x0, y: y0, w: x1 - x0, h: y1 - y0, rotation: 0, flipH: false, flipV: false };
    // Outline-only box: the pdf.js canvas underneath always shows the image's
    // TRUE current state. (A raw-image preview here would mask the canvas and
    // make a committed rotation/flip look like it reverted.) The box + handles
    // rotate/scale during a drag for live feedback; the canvas updates on commit.
    const box = h('div.ce-image-box', {
      style: { left: `${st.x * s}px`, top: `${st.y * s}px`, width: `${st.w * s}px`, height: `${st.h * s}px` },
    });
    box.addEventListener('pointerdown', (e) => this.onImagePointerDown(e, ps, img, st, box));
    // selection chrome (handles + controls) added on select
    this.decorateImage(ps, img, st, box);
    return box;
  }

  private applyImgTransform(box: HTMLElement, st: ImgState) {
    const s = this.scale;
    box.style.left = `${st.x * s}px`; box.style.top = `${st.y * s}px`;
    box.style.width = `${st.w * s}px`; box.style.height = `${st.h * s}px`;
    // reflect the pending in-session transform on the outline (flip shows as a
    // mirror of the box's scale so the handles/labels track it)
    box.style.transform = `rotate(${st.rotation}deg) scale(${st.flipH ? -1 : 1}, ${st.flipV ? -1 : 1})`;
  }

  private decorateImage(ps: PageState, img: ImageObjInfo, st: ImgState, box: HTMLElement) {
    box.classList.add('selected');
    box.appendChild(h('div.ce-controls',
      h('button.ce-ctl', { title: 'Flip horizontal', onclick: (e: Event) => { e.stopPropagation(); st.flipH = !st.flipH; this.applyImgTransform(box, st); this.commitImage(ps, img, st); } }, '⇋'),
      h('button.ce-ctl', { title: 'Flip vertical', onclick: (e: Event) => { e.stopPropagation(); st.flipV = !st.flipV; this.applyImgTransform(box, st); this.commitImage(ps, img, st); } }, '⇅'),
      h('button.ce-ctl', { title: 'Rotate 90°', onclick: (e: Event) => { e.stopPropagation(); st.rotation = (st.rotation + 90) % 360; this.applyImgTransform(box, st); this.commitImage(ps, img, st); } }, '⟳'),
      h('button.ce-ctl ce-ctl-danger', { title: 'Delete image', onclick: (e: Event) => { e.stopPropagation(); this.deleteImg(ps, img); } }, icon('trash', 13)),
    ));
    for (const dir of ['nw', 'ne', 'se', 'sw'] as const) {
      const hd = h('div.ce-handle ce-h-' + dir);
      hd.addEventListener('pointerdown', (e) => { e.stopPropagation(); this.startImageResize(e, ps, img, st, box, dir); });
      box.appendChild(hd);
    }
    const rot = h('div.ce-rotate-handle');
    rot.addEventListener('pointerdown', (e) => { e.stopPropagation(); this.startImageRotate(e, st, box, ps, img); });
    box.appendChild(rot);
  }

  private onImagePointerDown(e: PointerEvent, ps: PageState, img: ImageObjInfo, st: ImgState, box: HTMLElement) {
    if (e.button !== 0 || this.busy) return;
    e.preventDefault(); e.stopPropagation();
    const s = this.scale;
    const startX = e.clientX, startY = e.clientY;
    const ox = st.x, oy = st.y;
    let moved = false;
    const move = (ev: PointerEvent) => {
      const dx = (ev.clientX - startX) / s, dy = (ev.clientY - startY) / s;
      if (Math.abs(dx) > 1 || Math.abs(dy) > 1) moved = true;
      st.x = ox + dx; st.y = oy + dy;
      this.applyImgTransform(box, st);
    };
    const up = () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
      if (moved) this.commitImage(ps, img, st);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
  }

  private startImageResize(e: PointerEvent, ps: PageState, img: ImageObjInfo, st: ImgState, box: HTMLElement, dir: 'nw' | 'ne' | 'se' | 'sw') {
    e.preventDefault();
    const s = this.scale;
    const startX = e.clientX, startY = e.clientY;
    const o = { x: st.x, y: st.y, w: st.w, h: st.h };
    const move = (ev: PointerEvent) => {
      const dx = (ev.clientX - startX) / s, dy = (ev.clientY - startY) / s;
      let { x, y, w, h } = o;
      if (dir === 'se') { w = o.w + dx; h = o.h + dy; }
      else if (dir === 'sw') { x = o.x + dx; w = o.w - dx; h = o.h + dy; }
      else if (dir === 'ne') { y = o.y + dy; w = o.w + dx; h = o.h - dy; }
      else { x = o.x + dx; y = o.y + dy; w = o.w - dx; h = o.h - dy; }
      if (w < 8 || h < 8) return;
      st.x = x; st.y = y; st.w = w; st.h = h;
      this.applyImgTransform(box, st);
    };
    const up = () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
      this.commitImage(ps, img, st);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
  }

  private startImageRotate(e: PointerEvent, st: ImgState, box: HTMLElement, ps: PageState, img: ImageObjInfo) {
    e.preventDefault();
    const rect = box.getBoundingClientRect();
    const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
    const move = (ev: PointerEvent) => {
      const ang = Math.atan2(ev.clientY - cy, ev.clientX - cx) * 180 / Math.PI + 90;
      st.rotation = ev.shiftKey ? Math.round(ang / 15) * 15 : Math.round(ang);
      this.applyImgTransform(box, st);
    };
    const up = () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
      this.commitImage(ps, img, st);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
  }

  private async commitImage(ps: PageState, img: ImageObjInfo, st: ImgState) {
    if (this.busy || !store.doc) return;
    this.busy = true;
    const t = toast('Updating image…', { type: 'progress' });
    try {
      const out = await editImage(store.doc.bytes, {
        pageIndex: ps.srcIndex,
        imageIndex: img.index,
        oldBbox: img.bbox,
        box: { x: st.x, y: st.y, w: st.w, h: st.h },
        rotation: st.rotation,
        flipH: st.flipH,
        flipV: st.flipV,
      });
      this.cache.clear();
      await this.applyEdit(out, [ps.srcIndex]);
      t.close();
      toast('Image updated.', { type: 'success' });
    } catch (err) {
      t.close();
      console.error(err);
      toast('Couldn’t edit that image. See the console for details.', { type: 'error' });
    } finally {
      this.busy = false;
      this.render();
    }
  }

  private async deleteImg(ps: PageState, img: ImageObjInfo) {
    if (this.busy || !store.doc) return;
    this.busy = true;
    const t = toast('Deleting image…', { type: 'progress' });
    try {
      const out = await deleteImage(store.doc.bytes, ps.srcIndex, img.index, img.bbox);
      this.cache.clear();
      await this.applyEdit(out, [ps.srcIndex]);
      t.close();
      toast('Image deleted.', { type: 'success' });
    } catch (err) {
      t.close();
      console.error(err);
      toast('Couldn’t delete that image. See the console for details.', { type: 'error' });
    } finally {
      this.busy = false;
      this.render();
    }
  }
}
