/// <reference lib="webworker" />
/**
 * mupdf native-editing worker.
 *
 * mupdf.js is a ~10MB single-threaded WASM engine (AGPL). It runs here in a
 * Web Worker so heavy content-stream surgery never blocks the UI. The worker
 * caches one open PDFDocument keyed by a monotonic `gen`; the client re-sends
 * the full bytes only when they change (see core/mupdf.ts).
 *
 * All geometry crossing the wire is in mupdf DEVICE space (top-left, y-down,
 * points) — the same space as YeetPDF's overlay for an unrotated page. When we
 * WRITE content (redraw text/images) we convert to PDF page space (bottom-left,
 * y-up) using the inverse of `page.getTransform()`, which also folds in the
 * media-box offset and page rotation correctly.
 */
import { installHandler } from './mupdf-guard'; // must be first: message buffer + wasm bytes
import * as mupdf from 'mupdf';
import { familyById, fontFileUrl, type FontVariant } from '../core/fonts';
import type {
  MupdfRequest,
  MupdfResponse,
  PageContent,
  TextSpanInfo,
  ImageObjInfo,
  TextEditParams,
  Rect4,
  Mat6,
} from '../core/mupdf-types';

const M = mupdf.Matrix;

let doc: mupdf.PDFDocument | null = null;
let fontCounter = 0;

// The exact source Font object behind each span from the last `queryPage` of
// that page, keyed by span index — lets `doEditText` reuse a span's own glyphs
// (see `reuseOriginalFont` on TextEditParams) instead of always substituting.
// Cleared whenever the underlying doc is swapped/reopened, since span indices
// and font identities from a prior generation are meaningless afterwards.
const spanFontCache = new Map<number, Map<number, mupdf.Font>>();
function clearSpanFontCache() {
  for (const m of spanFontCache.values()) for (const f of m.values()) f.destroy();
  spanFontCache.clear();
}
// Set once any edit embeds a bundled TTF, so the export-time `compact` op knows
// to subset the (full) embedded fonts down to the glyphs actually used.
let hasEmbeddedCustomFont = false;

// Same-origin font bytes, fetched on demand and cached in the worker. Mirrors
// the wasm fetch in mupdf-guard; the SW runtime cache also persists them offline.
const fontBytesCache = new Map<string, Uint8Array>();
async function loadFontBytes(id: string, variant: FontVariant): Promise<Uint8Array | null> {
  const url = fontFileUrl(id, variant);
  if (!url) return null;
  const key = `${id}:${variant}`;
  const cached = fontBytesCache.get(key);
  if (cached) return cached;
  try {
    const res = await fetch(url);
    if (!res.ok) return null;
    const bytes = new Uint8Array(await res.arrayBuffer());
    fontBytesCache.set(key, bytes);
    return bytes;
  } catch {
    return null;
  }
}

function openDoc(bytes: Uint8Array): mupdf.PDFDocument {
  const opened = mupdf.Document.openDocument(bytes, 'application/pdf');
  const pdf = opened.asPDF();
  if (!pdf) {
    opened.destroy();
    throw new Error('Not a PDF document');
  }
  return pdf;
}

/* ---------- small helpers ---------- */

function clamp01(n: number): number {
  return n < 0 ? 0 : n > 1 ? 1 : n;
}
function hex2(n: number): string {
  return Math.round(clamp01(n) * 255).toString(16).padStart(2, '0');
}
/** mupdf Color (1/3/4 normalized components) → #rrggbb. */
function colorToHex(c: number[] | undefined): string {
  if (!c || !c.length) return '#000000';
  if (c.length === 1) { const g = hex2(c[0]); return `#${g}${g}${g}`; }
  if (c.length >= 3 && c.length < 4) return `#${hex2(c[0])}${hex2(c[1])}${hex2(c[2])}`;
  if (c.length === 4) {
    // CMYK → RGB
    const [cy, mg, ye, k] = c;
    return `#${hex2((1 - cy) * (1 - k))}${hex2((1 - mg) * (1 - k))}${hex2((1 - ye) * (1 - k))}`;
  }
  return '#000000';
}
/** #rrggbb → [r,g,b] normalized 0..1. */
function hexToRgb(hex: string): [number, number, number] {
  const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
  if (!m) return [0, 0, 0];
  const n = parseInt(m[1], 16);
  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
function normRect(r: number[]): Rect4 {
  return [Math.min(r[0], r[2]), Math.min(r[1], r[3]), Math.max(r[0], r[2]), Math.max(r[1], r[3])];
}
function readNumberArray(obj: mupdf.PDFObject | null, len: number): number[] | null {
  if (!obj || !obj.isArray() || obj.length < len) return null;
  const out: number[] = [];
  for (let i = 0; i < len; i++) out.push(obj.get(i).asNumber());
  return out;
}
/** Read the page's user-space media box, normalized. */
function pageMediaBox(page: mupdf.PDFPage): Rect4 {
  const arr = readNumberArray(page.getObject().getInheritable('MediaBox'), 4);
  if (arr) return normRect(arr);
  return normRect(page.getBounds() as unknown as number[]);
}
function pageRotation(page: mupdf.PDFPage): number {
  const r = page.getObject().getInheritable('Rotate');
  const n = r && r.isNumber() ? r.asNumber() : 0;
  return ((n % 360) + 360) % 360;
}
/** Escape a JS string as a PDF literal string body for a simple-Latin font. */
function pdfLiteral(text: string): string {
  let out = '';
  for (const ch of text) {
    const code = ch.codePointAt(0)!;
    if (ch === '\\') out += '\\\\';
    else if (ch === '(') out += '\\(';
    else if (ch === ')') out += '\\)';
    else if (ch === '\n') out += ' ';
    else if (ch === '\r') out += '';
    else if (code <= 0xff) out += ch; // Latin-1 / WinAnsi range
    else out += '?'; // outside simple-font range — honest fallback
  }
  return out;
}
/** True when `font` has an actual glyph (not .notdef) for every character of
 *  `text` — the check that gates reusing a span's own source font. Restricted
 *  to the Latin-1 range, matching the simple-font encoding this editor draws
 *  with (see `pdfLiteral`). */
function fontCoversText(font: mupdf.Font, text: string): boolean {
  for (const ch of text) {
    if (ch === '\n' || ch === '\r') continue;
    const code = ch.codePointAt(0)!;
    if (code > 0xff) return false;
    if (font.encodeCharacter(code) === 0) return false;
  }
  return true;
}

/** Choose a base-14 font name from style flags. */
function base14(serif: boolean, mono: boolean, bold: boolean, italic: boolean): string {
  if (mono) return bold && italic ? 'Courier-BoldOblique' : bold ? 'Courier-Bold' : italic ? 'Courier-Oblique' : 'Courier';
  if (serif) return bold && italic ? 'Times-BoldItalic' : bold ? 'Times-Bold' : italic ? 'Times-Italic' : 'Times-Roman';
  return bold && italic ? 'Helvetica-BoldOblique' : bold ? 'Helvetica-Bold' : italic ? 'Helvetica-Oblique' : 'Helvetica';
}

/**
 * Bounding rects (device space) of every text run painted with render mode 3
 * ("neither fill nor stroke" — invisible), via a raw fz_device pass. This is
 * exactly the OCR text layer a "scan to searchable PDF" tool overlays on the
 * page image: it never reaches `fillText`/`strokeText`, only `ignoreText`.
 */
function collectInvisibleTextRects(page: mupdf.PDFPage): Rect4[] {
  const rects: Rect4[] = [];
  const dev = new mupdf.Device({
    ignoreText(text, ctm) {
      rects.push(normRect(text.getBounds(null as unknown as mupdf.StrokeState, ctm) as unknown as number[]));
    },
  });
  try {
    page.run(dev, M.identity);
  } finally {
    dev.close();
    dev.destroy();
  }
  return rects;
}

/* ---------- page content query ---------- */

function queryPage(pageIndex: number): PageContent {
  const page = doc!.loadPage(pageIndex);
  try {
    const mediaBox = pageMediaBox(page);
    const rotation = pageRotation(page);
    const spans: TextSpanInfo[] = [];
    const images: ImageObjInfo[] = [];
    const pageFonts = new Map<number, mupdf.Font>();

    // group consecutive same-style glyphs on a line into one editable span
    let cur: {
      chars: string[]; x0: number; y0: number; x1: number; y1: number;
      ox: number; oy: number; size: number; font: mupdf.Font; fontKey: string; color: string;
      serif: boolean; mono: boolean; bold: boolean; italic: boolean; dir: [number, number];
    } | null = null;

    const flush = () => {
      if (!cur) return;
      const text = cur.chars.join('').replace(/\s+$/,'');
      if (text.length) {
        pageFonts.set(spans.length, cur.font);
        spans.push({
          index: spans.length,
          text,
          bbox: [cur.x0, cur.y0, cur.x1, cur.y1],
          origin: [cur.ox, cur.oy],
          size: cur.size,
          fontName: cur.fontKey,
          serif: cur.serif, mono: cur.mono, bold: cur.bold, italic: cur.italic,
          color: cur.color,
          dir: cur.dir,
          invisible: false, // filled in below, once `images` is known
        });
      }
      cur = null;
    };

    const stext = page.toStructuredText('preserve-spans,preserve-images');
    try {
      let lineDir: [number, number] = [1, 0];
      stext.walk({
        beginLine(_bbox, _wmode, direction) {
          lineDir = [direction[0], direction[1]];
        },
        onChar(c, origin, font, size, quad, color) {
          const name = font.getName();
          const key = name + '@' + size.toFixed(1);
          // quad = [ulx,uly,urx,ury,llx,lly,lrx,lry]
          const qx0 = Math.min(quad[0], quad[2], quad[4], quad[6]);
          const qy0 = Math.min(quad[1], quad[3], quad[5], quad[7]);
          const qx1 = Math.max(quad[0], quad[2], quad[4], quad[6]);
          const qy1 = Math.max(quad[1], quad[3], quad[5], quad[7]);
          if (cur && cur.fontKey === key) {
            cur.chars.push(c);
            cur.x0 = Math.min(cur.x0, qx0); cur.y0 = Math.min(cur.y0, qy0);
            cur.x1 = Math.max(cur.x1, qx1); cur.y1 = Math.max(cur.y1, qy1);
          } else {
            flush();
            cur = {
              chars: [c], x0: qx0, y0: qy0, x1: qx1, y1: qy1,
              ox: origin[0], oy: origin[1], size, font, fontKey: key, color: colorToHex(color),
              serif: font.isSerif(), mono: font.isMono(), bold: font.isBold(), italic: font.isItalic(),
              dir: lineDir,
            };
          }
        },
        endLine() { flush(); },
        onImageBlock(bbox, transform, image) {
          images.push({
            index: images.length,
            bbox: normRect(bbox as unknown as number[]),
            matrix: [transform[0], transform[1], transform[2], transform[3], transform[4], transform[5]] as Mat6,
            width: image.getWidth(), height: image.getHeight(),
          });
        },
      });
      flush();
    } finally {
      stext.destroy();
    }

    // Flag spans that are actually invisible glyphs (PDF text render mode 3) —
    // the hidden OCR layer scanned pages place over their page image. Detecting
    // this needs a second content-stream pass (the fz_device text-paint hooks;
    // structured text doesn't expose render mode), so only pay for it on pages
    // that actually have an image to hide behind — the common all-vector-text
    // document never runs it.
    if (spans.length && images.length) {
      const invisibleRects = collectInvisibleTextRects(page);
      if (invisibleRects.length) {
        for (const span of spans) {
          const [x0, y0, x1, y1] = span.bbox;
          const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
          span.invisible = invisibleRects.some((r) => cx >= r[0] && cx <= r[2] && cy >= r[1] && cy <= r[3]);
        }
      }
    }

    const prevFonts = spanFontCache.get(pageIndex);
    if (prevFonts) for (const f of prevFonts.values()) f.destroy();
    spanFontCache.set(pageIndex, pageFonts);

    return {
      pageIndex,
      mediaBox,
      width: mediaBox[2] - mediaBox[0],
      height: mediaBox[3] - mediaBox[1],
      rotation,
      spans,
      images,
    };
  } finally {
    page.destroy();
  }
}

/* ---------- content-writing helpers ---------- */

/** device→page-space transform (inverse of page ctm). */
function invCtm(page: mupdf.PDFPage): Mat6 {
  return M.invert(page.getTransform()) as Mat6;
}
function apply(m: Mat6, x: number, y: number): [number, number] {
  return [x * m[0] + y * m[2] + m[4], x * m[1] + y * m[3] + m[5]];
}

/** Ensure page /Resources /Font exists; return the font dict. */
function ensureFontDict(page: mupdf.PDFPage): mupdf.PDFObject {
  const pageObj = page.getObject();
  let res = pageObj.get('Resources');
  if (!res.isDictionary()) { res = doc!.newDictionary(); pageObj.put('Resources', res); }
  let fonts = res.get('Font');
  if (!fonts.isDictionary()) { fonts = doc!.newDictionary(); res.put('Font', fonts); }
  return fonts;
}
/** Ensure page /Resources /XObject exists; return it. */
function ensureXObjectDict(page: mupdf.PDFPage): mupdf.PDFObject {
  const pageObj = page.getObject();
  let res = pageObj.get('Resources');
  if (!res.isDictionary()) { res = doc!.newDictionary(); pageObj.put('Resources', res); }
  let xobj = res.get('XObject');
  if (!xobj.isDictionary()) { xobj = doc!.newDictionary(); res.put('XObject', xobj); }
  return xobj;
}
/** Append a content stream to the page's /Contents. */
function appendContent(page: mupdf.PDFPage, cs: string): void {
  const pageObj = page.getObject();
  const extra = doc!.addStream(cs, {});
  const contents = pageObj.get('Contents');
  if (contents.isArray()) {
    contents.push(extra);
  } else if (contents.isNull()) {
    pageObj.put('Contents', extra);
  } else {
    const arr = doc!.newArray();
    arr.push(contents);
    arr.push(extra);
    pageObj.put('Contents', arr);
  }
}

/**
 * Serialize the edited doc back to bytes.
 *
 * `secure` (redaction) does a full rewrite with `garbage` (level 1) — a
 * garbage-collect that physically DROPS the objects the redaction orphaned (the
 * pre-redaction content streams / image XObjects that still hold the removed
 * glyphs and pixels). That is the whole point of redaction, and it is exactly
 * what makes the content unrecoverable. We use bare `garbage`, NOT the heavier
 * `garbage=deduplicate,compress,sanitize`: the extra dedup/compress/sanitize do
 * nothing for *removal* (the level-1 collect already drops every orphan — proven
 * by decompressing every stream in the output) but they re-hash + re-deflate the
 * whole document, which is what made redaction hang. Measured on a 13 MB doc:
 * `garbage` 74 ms with the secret physically gone, vs 12 s for the heavy option
 * with the same result (~165× faster, ~4% larger file).
 *
 * Every OTHER edit (text / image / delete-image) saves INCREMENTALLY: mupdf
 * copies the original bytes and appends only the objects this edit changed, so
 * the cost is O(edit) — ~1 ms regardless of document size. The replaced content
 * stays in the file's history, which is fine for an edit (unlike a redaction)
 * and is compacted away when the user exports.
 *
 * We deliberately do NOT gate on `canBeSavedIncrementally()`: mupdf turns that
 * flag OFF once a page has been redacted, and our text/image edits use redaction
 * internally to erase the old glyph/image, so it is false for every edit. Forcing
 * an incremental save on a non-repaired doc still yields a valid, correctly-
 * rendering PDF (verified). The one case we must avoid is a REPAIRED source
 * (broken/absent base xref to append to) — there a full write is the only safe
 * option, so we fall back to it.
 */
function saveBytes(secure: boolean): Uint8Array {
  const opts = secure || doc!.wasRepaired() ? 'garbage' : 'incremental';
  const buf = doc!.saveToBuffer(opts);
  let out: Uint8Array;
  try { out = buf.asUint8Array().slice(); } finally { buf.destroy(); }
  // the doc reopen below invalidates any cached span→font mapping; the client
  // always re-queries touched pages before another edit is possible, so this
  // just guarantees no reference ever crosses a doc generation.
  clearSpanFontCache();
  // Re-open from the clean output so the cached doc always matches the bytes we
  // return. A full rewrite (`garbage`) compacts + renumbers IN PLACE, so without
  // this a 2nd edit runs against garbage-collected state and the just-edited
  // object vanishes (bug #4). For incremental saves the re-open also re-bases the
  // next append (keeps each delta small) and clears the redaction flag. mupdf's
  // open is lazy (parses the xref, not the streams), so this is cheap even at
  // 20 MB — unlike the full rewrite it replaces on the edit path.
  doc!.destroy();
  doc = openDoc(out);
  return out;
}

/* ---------- edit operations ---------- */

/**
 * Rects that erase EXACTLY the glyphs of one text span — one small rect per
 * glyph, covering only the middle of that glyph's quad.
 *
 * mupdf drops every glyph whose CHAR QUAD *overlaps* a redaction rect, and a
 * char quad is the full line box (~1.37em tall for Helvetica) — taller than the
 * leading of a normally-set document. Handing it the span's own box therefore
 * also caught the lines above and below: 11pt text on 12pt leading gives boxes
 * that overlap by 3.1pt, so editing one line silently deleted its neighbours,
 * and a tightly-set label sharing the line lost its last glyph.
 *
 * A per-glyph rect overlaps the glyph it came from by construction, while a
 * neighbouring glyph has to reach the CENTRE of ours to be caught — i.e. only
 * text genuinely drawn on top of this span, which "replace this span" should
 * erase anyway.
 */
function spanGlyphRects(page: mupdf.PDFPage, bbox: Rect4): Rect4[] {
  const [bx0, by0, bx1, by1] = normRect(bbox);
  const rects: Rect4[] = [];
  const stext = page.toStructuredText('preserve-spans');
  try {
    stext.walk({
      onChar(c, _origin, _font, _size, quad) {
        // whitespace paints nothing, so there is no ink to remove — and skipping
        // it keeps the space that separates this span from the next one (whose
        // quad sits between them) out of the redaction
        if (!c.trim()) return;
        const x0 = Math.min(quad[0], quad[2], quad[4], quad[6]);
        const y0 = Math.min(quad[1], quad[3], quad[5], quad[7]);
        const x1 = Math.max(quad[0], quad[2], quad[4], quad[6]);
        const y1 = Math.max(quad[1], quad[3], quad[5], quad[7]);
        const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
        // a glyph belongs to the span when its CENTRE is inside the span box;
        // the slack absorbs the rounding of the box's round trip to the client
        if (cx < bx0 - 0.5 || cx > bx1 + 0.5 || cy < by0 - 0.5 || cy > by1 + 0.5) return;
        const hw = (x1 - x0) / 4, hh = (y1 - y0) / 6;
        if (hw <= 0 || hh <= 0) return;
        rects.push([cx - hw, cy - hh, cx + hw, cy + hh]);
      },
    });
  } finally {
    stext.destroy();
  }
  return rects;
}

/** Apply `rects` to the page as text-only redactions (no black boxes). */
function redactTextRects(page: mupdf.PDFPage, rects: Rect4[]): void {
  // One page.applyRedactions() covers every rect in a single content rewrite,
  // but it also applies any Redact annotation the SOURCE document already
  // carries — silently destroying content the user never marked. That's rare, so
  // only pay the per-annotation cost (~6× slower: 190 ms vs 30 ms for an 85-glyph
  // span) on the documents that actually ship unapplied redaction marks.
  const pending = page.getAnnotations().some((a) => a.getType() === 'Redact');
  for (const r of rects) {
    const annot = page.createAnnotation('Redact');
    annot.setRect(normRect(r));
    if (pending) {
      annot.applyRedaction(
        0, // no black box
        mupdf.PDFPage.REDACT_IMAGE_NONE,
        mupdf.PDFPage.REDACT_LINE_ART_NONE,
        mupdf.PDFPage.REDACT_TEXT_REMOVE,
      );
    }
  }
  if (!pending) {
    page.applyRedactions(
      false, // no black boxes
      mupdf.PDFPage.REDACT_IMAGE_NONE,
      mupdf.PDFPage.REDACT_LINE_ART_NONE,
      mupdf.PDFPage.REDACT_TEXT_REMOVE,
    );
  }
}

function doRedact(marks: { pageIndex: number; rect: Rect4 }[], imageMethod: number): Uint8Array {
  const byPage = new Map<number, Rect4[]>();
  for (const m of marks) {
    if (!byPage.has(m.pageIndex)) byPage.set(m.pageIndex, []);
    byPage.get(m.pageIndex)!.push(m.rect);
  }
  for (const [pageIndex, rects] of byPage) {
    const page = doc!.loadPage(pageIndex);
    try {
      for (const r of rects) {
        const annot = page.createAnnotation('Redact');
        annot.setRect(normRect(r));
      }
      page.applyRedactions(
        true, // paint black boxes where content was removed
        imageMethod,
        mupdf.PDFPage.REDACT_LINE_ART_REMOVE_IF_TOUCHED,
        mupdf.PDFPage.REDACT_TEXT_REMOVE,
      );
    } finally {
      page.destroy();
    }
  }
  return saveBytes(true); // redaction must physically remove content
}

async function doEditText(p: TextEditParams): Promise<Uint8Array> {
  const page = doc!.loadPage(p.pageIndex);
  try {
    // 1) remove ONLY this span's own glyphs (keep images/vectors, no black box).
    //    Falls back to the span box when nothing is found there — a bbox that no
    //    longer matches the page is better handled by clearing it than by drawing
    //    the replacement on top of whatever is still there.
    const glyphs = spanGlyphRects(page, p.bbox);
    redactTextRects(page, glyphs.length ? glyphs : [normRect(p.bbox)]);

    // 2) draw the replacement text at the original baseline
    if (p.text.trim().length) {
      // Font choice, in priority order:
      //  1) the span's OWN source font, byte-identical glyphs — but only when the
      //     user left family/bold/italic at their original values (a manual
      //     override always wins) AND every character being drawn already has a
      //     glyph in it. A newly-typed character the original (often subsetted)
      //     font never included falls through to substitution below.
      //  2) a bundled TTF that metric-matches the document's family (Latin only),
      //  3) a base-14 metric substitute — needs no embedding, already matches
      //     Arial/Times/Courier documents.
      const reused = p.reuseOriginalFont ? spanFontCache.get(p.pageIndex)?.get(p.spanIndex) : undefined;
      const canReuse = !!reused && fontCoversText(reused, p.text);

      let font: mupdf.Font;
      let embeddedCustom = false;
      if (canReuse) {
        font = reused!;
      } else {
        const fam = p.fontFamily ? familyById(p.fontFamily) : undefined;
        const variant: FontVariant = p.variant ?? 'regular';
        const bytes = fam && !fam.core && fam.psName ? await loadFontBytes(fam.id, variant) : null;
        embeddedCustom = !!(bytes && fam?.psName);
        font = embeddedCustom
          ? new mupdf.Font(fam!.psName!, bytes!)
          : new mupdf.Font(base14(p.serif, p.mono, p.bold, p.italic));
      }

      // measure the drawn text's advance (for the underline) before dropping the font
      let emAdvance = 0;
      if (p.underline) {
        try {
          for (const ch of p.text) {
            if (ch === '\n' || ch === '\r') continue;
            emAdvance += font.advanceGlyph(font.encodeCharacter(ch.codePointAt(0)!));
          }
        } catch { emAdvance = p.text.length * 0.5; }
      }

      let fontRes: mupdf.PDFObject;
      try {
        fontRes = doc!.addSimpleFont(font, 'Latin');
      } finally {
        // the reused font is owned by spanFontCache (destroyed on its own
        // lifecycle) — only drop the ones we constructed just for this draw.
        if (!canReuse) font.destroy();
      }
      if (embeddedCustom || canReuse) hasEmbeddedCustomFont = true;
      const resName = 'YeetF' + ++fontCounter;
      ensureFontDict(page).put(resName, fontRes);

      const [px, py] = apply(invCtm(page), p.origin[0], p.origin[1]);
      const [r, g, b] = hexToRgb(p.color);
      let cs = '';
      // The old glyphs were invisible (a scanned page's OCR layer) — the
      // redaction above cleared them, but there was nothing to visibly remove.
      // The page image is still showing the original scanned characters, so
      // paint an opaque box over them before drawing the new (visible) text,
      // or the two would appear stacked on top of each other.
      if (p.invisible) {
        const pad = p.size * 0.2;
        const c1 = apply(invCtm(page), p.bbox[0] - pad, p.bbox[1] - pad);
        const c2 = apply(invCtm(page), p.bbox[2] + pad, p.bbox[3] + pad);
        const rx = Math.min(c1[0], c2[0]), ry = Math.min(c1[1], c2[1]);
        const rw = Math.abs(c2[0] - c1[0]), rh = Math.abs(c2[1] - c1[1]);
        cs += `q 1 1 1 rg ${rx.toFixed(2)} ${ry.toFixed(2)} ${rw.toFixed(2)} ${rh.toFixed(2)} re f Q `;
      }
      cs +=
        `q ${r.toFixed(4)} ${g.toFixed(4)} ${b.toFixed(4)} rg BT /${resName} ` +
        `${p.size.toFixed(2)} Tf 1 0 0 1 ${px.toFixed(2)} ${py.toFixed(2)} Tm (${pdfLiteral(p.text)}) Tj ET Q`;
      // underline: a stroked line just below the baseline, spanning the drawn width
      if (p.underline && emAdvance > 0) {
        const textW = emAdvance * p.size;
        const uy = py - p.size * 0.12;
        const uw = Math.max(0.5, p.size * 0.06);
        cs +=
          ` q ${r.toFixed(4)} ${g.toFixed(4)} ${b.toFixed(4)} RG ${uw.toFixed(2)} w ` +
          `${px.toFixed(2)} ${uy.toFixed(2)} m ${(px + textW).toFixed(2)} ${uy.toFixed(2)} l S Q`;
      }
      appendContent(page, cs);
    }
    return saveBytes(false); // incremental — fast on large docs
  } finally {
    page.destroy();
  }
}

/**
 * Compose the requested transform onto the image's EXISTING placement, so
 * repeated edits (rotate/flip/scale/move) accumulate instead of resetting.
 *
 * `tOld` is the image's current placement from `onImageBlock` — but that uses
 * image (0,0)=top-left / v-DOWN, while our draw path uses (0,0)=bottom-left /
 * v-UP, so we convert with a v-flip first (else a rotation becomes a transpose).
 * `D` is the device-space delta: map the old bbox → the new box (scale+translate,
 * centered) then rotate/flip about the new center. Returns the image→page matrix.
 */
function composedImageCm(
  page: mupdf.PDFPage, tOld: Mat6, oldBbox: Rect4,
  box: { x: number; y: number; w: number; h: number }, rotation: number, flipH: boolean, flipV: boolean,
): Mat6 {
  const ow = oldBbox[2] - oldBbox[0], oh = oldBbox[3] - oldBbox[1];
  const ocx = oldBbox[0] + ow / 2, ocy = oldBbox[1] + oh / 2;
  const ncx = box.x + box.w / 2, ncy = box.y + box.h / 2;
  // A: old bbox → new box (axis-aligned scale + recenter)
  const A = M.concat(M.translate(-ocx, -ocy), M.concat(M.scale(box.w / ow, box.h / oh), M.translate(ncx, ncy)));
  // B: rotate (CW on screen, device y-down) + flip about the new center
  const rad = (rotation * Math.PI) / 180;
  const c = Math.cos(rad), s = Math.sin(rad);
  const R: Mat6 = [c, s, -s, c, 0, 0];
  const F: Mat6 = [flipH ? -1 : 1, 0, 0, flipV ? -1 : 1, 0, 0];
  const B = M.concat(M.translate(-ncx, -ncy), M.concat(F, M.concat(R, M.translate(ncx, ncy))));
  const D = M.concat(A, B) as Mat6;
  const tOldMine = M.concat([1, 0, 0, -1, 0, 1], tOld) as Mat6; // v-down → v-up
  const tNew = M.concat(tOldMine, D) as Mat6;
  return M.concat(tNew, invCtm(page)) as Mat6;
}

function doEditImage(p: {
  pageIndex: number; imageIndex: number; oldBbox: Rect4;
  box: { x: number; y: number; w: number; h: number }; rotation: number; flipH: boolean; flipV: boolean;
}): Uint8Array {
  const page = doc!.loadPage(p.pageIndex);
  try {
    // Capture an independent XObject copy of the target image + its CURRENT
    // placement transform while the stext handle is alive (addImage deep-copies
    // into the doc, so it survives redaction below).
    let imgObj: mupdf.PDFObject | null = null;
    let tOld: Mat6 | null = null;
    let i = 0;
    const stext = page.toStructuredText('preserve-images');
    try {
      stext.walk({
        onImageBlock(_bbox, transform, image) {
          if (i === p.imageIndex) {
            imgObj = doc!.addImage(image);
            tOld = [transform[0], transform[1], transform[2], transform[3], transform[4], transform[5]];
          }
          i++;
        },
      });
    } finally {
      stext.destroy();
    }
    if (!imgObj || !tOld) throw new Error('Image not found on page');

    // 1) remove the OLD placement first. applyRedaction rewrites the page's
    //    /Resources, so any XObject we register must be added AFTER this, else
    //    it gets dropped and the `Do` fails with "cannot find XObject".
    const annot = page.createAnnotation('Redact');
    annot.setRect(normRect(p.oldBbox));
    annot.applyRedaction(
      0,
      mupdf.PDFPage.REDACT_IMAGE_REMOVE,
      mupdf.PDFPage.REDACT_LINE_ART_NONE,
      mupdf.PDFPage.REDACT_TEXT_NONE,
    );

    // 2) register the image XObject + draw it at the new transform
    const resName = 'YeetImg' + ++fontCounter;
    ensureXObjectDict(page).put(resName, imgObj);
    const cm = composedImageCm(page, tOld, p.oldBbox, p.box, p.rotation, p.flipH, p.flipV);
    const cs =
      `q ${cm[0].toFixed(5)} ${cm[1].toFixed(5)} ${cm[2].toFixed(5)} ${cm[3].toFixed(5)} ` +
      `${cm[4].toFixed(3)} ${cm[5].toFixed(3)} cm /${resName} Do Q`;
    appendContent(page, cs);
    return saveBytes(false); // incremental — fast on large docs
  } finally {
    page.destroy();
  }
}

/**
 * Flatten the doc to clean, export-ready bytes. `garbage` (level 1) drops the
 * superseded objects our incremental edits leave behind — including the edited-
 * out old text — and collapses the incremental xref chain into one xref, WITHOUT
 * the expensive dedup/compress/sanitize (measured ~200 ms vs ~20 s on a 14 MB
 * doc). mupdf stays memory-light here, which is the whole point: it lets the
 * exporter skip pdf-lib entirely for a pure native-edit export instead of
 * rebuilding the object graph and OOM-crashing the tab on a large document.
 */
function doCompact(): Uint8Array {
  doc!.setMetaData('info:Producer', 'YeetPDF (yeetpdf.com)');
  doc!.setMetaData('info:Creator', 'YeetPDF');
  // A native text edit embeds a FULL copy of the chosen bundled TTF; subset every
  // embedded font down to the glyphs actually used before flattening for export,
  // so repeated edits don't bloat the file.
  if (hasEmbeddedCustomFont) {
    try { doc!.subsetFonts(); } catch (e) { console.warn('subsetFonts failed', e); }
  }
  const buf = doc!.saveToBuffer('garbage');
  let out: Uint8Array;
  try { out = buf.asUint8Array().slice(); } finally { buf.destroy(); }
  clearSpanFontCache();
  // `garbage` renumbers in place — re-open so the cached doc still matches the
  // bytes if the user keeps editing after exporting (bug #4).
  doc!.destroy();
  doc = openDoc(out);
  return out;
}

function doDeleteImage(pageIndex: number, _imageIndex: number, bbox: Rect4): Uint8Array {
  const page = doc!.loadPage(pageIndex);
  try {
    const annot = page.createAnnotation('Redact');
    annot.setRect(normRect(bbox));
    annot.applyRedaction(
      0,
      mupdf.PDFPage.REDACT_IMAGE_REMOVE,
      mupdf.PDFPage.REDACT_LINE_ART_NONE,
      mupdf.PDFPage.REDACT_TEXT_NONE,
    );
    return saveBytes(false); // incremental — fast on large docs
  } finally {
    page.destroy();
  }
}

/* ---------- message loop ---------- */

installHandler(async (e: MessageEvent<MupdfRequest>) => {
  const req = e.data;
  const respond = (r: Omit<MupdfResponse, 'id'>, transfer: Transferable[] = []) => {
    (self as unknown as Worker).postMessage({ id: req.id, ...r } as MupdfResponse, transfer);
  };
  try {
    if (req.bytes) {
      doc?.destroy();
      doc = openDoc(req.bytes);
      clearSpanFontCache();
    }
    if (!doc) throw new Error('No document loaded');

    const p = req.payload;
    switch (p.op) {
      case 'query-page': {
        respond({ ok: true, page: queryPage(p.pageIndex) });
        break;
      }
      case 'redact': {
        const imgMethod =
          p.imageMode === 'remove' ? mupdf.PDFPage.REDACT_IMAGE_REMOVE
          : p.imageMode === 'none' ? mupdf.PDFPage.REDACT_IMAGE_NONE
          : mupdf.PDFPage.REDACT_IMAGE_PIXELS;
        const out = doRedact(p.marks, imgMethod);
        respond({ ok: true, output: out }, [out.buffer]);
        break;
      }
      case 'edit-text': {
        const out = await doEditText(p.params);
        respond({ ok: true, output: out }, [out.buffer]);
        break;
      }
      case 'edit-image': {
        const out = doEditImage(p.params);
        respond({ ok: true, output: out }, [out.buffer]);
        break;
      }
      case 'delete-image': {
        const out = doDeleteImage(p.pageIndex, p.imageIndex, p.bbox);
        respond({ ok: true, output: out }, [out.buffer]);
        break;
      }
      case 'compact': {
        const out = doCompact();
        respond({ ok: true, output: out }, [out.buffer]);
        break;
      }
      default:
        throw new Error('Unknown op');
    }
  } catch (err) {
    respond({ ok: false, error: err instanceof Error ? err.message : String(err) });
  }
});
