// ═══════════════════════════════════════════════════════ LEON PDF — the tools
//
// The viewer (office-pdf.jsx) is where a document is READ and marked up. This
// file is where it is CHANGED: combined, split, packaged, branded, redacted,
// compared, mined for data, made smaller, exported and given a policy.
//
// Everything here sits on top of office-pdf-core.jsx and never touches
// window.pdfjsLib or window.PDFLib itself. That is not tidiness — it is the
// reason "the original is never overwritten" is true rather than promised:
// there is exactly one function that writes an asset (officePdfPutAsset), it
// only ever creates, and every tool below ends by creating a NEW one.
//
// THREE THINGS THIS FILE WILL NOT DO, said here once and again on the screens
// where someone would look for them:
//   · There is no server. Every page rendered, diffed, rasterized or written
//     below happens on the machine the browser is running on, which is why the
//     job indicator exists at all.
//   · There is no model. "AI summarise the changes" and "AI read this invoice"
//     are rendered disabled with the reason, and what is built instead is the
//     deterministic version — a real LCS diff, a real pixel diff, real
//     coordinate-clustered table extraction. Nothing here invents a confidence
//     score; where a number is shown it is measured from the document.
//   · pdf-lib cannot encrypt. Restrict-editing / restrict-printing / password
//     are recorded as LEON POLICY, enforced by the Hub's own permissions, and
//     the exported file carries no encryption. Implying otherwise would be the
//     single most dangerous lie this module could tell.
//
// Naming: everything is prefixed OfficePdfTool / officePdfTool / PDFTOOL_. The
// core owns dozens of officePdf* names and the viewer owns OfficePdfView*; a
// duplicate top-level const in this app is not a scoping error, it is a blank
// page, because all six source files share one global scope.

// ── The ten tools ─────────────────────────────────────────────────────────
const PDFTOOL_TOOLS = {
  organize: { icon: '✂️', label: 'Split & extract pages',
              blurb: 'Break this document into smaller ones, or pull pages out as a new PDF.' },
  combine:  { icon: '🧩', label: 'Combine',
              blurb: 'Add PDFs, images and LEON documents, order them, write one file.' },
  package:  { icon: '📚', label: 'Package builder',
              blurb: 'Ordered sections with dividers and a contents page — submittal, closeout, procurement.' },
  brand:    { icon: '🏷️', label: 'Brand & finish',
              blurb: 'Page labels, headers and footers, watermarks, stamps and Bates numbering.' },
  redact:   { icon: '⬛', label: 'Redact',
              blurb: 'Mark, review, then apply — content is removed from the file, not covered over.' },
  compare:  { icon: '⇄', label: 'Compare',
              blurb: 'Two documents or two revisions: what the text says, and what the page looks like.' },
  extract:  { icon: '📤', label: 'Extract data',
              blurb: 'Tables and labelled fields off the text layer, reviewed before anything is created.' },
  optimize: { icon: '🗜️', label: 'Optimize',
              blurb: 'Smaller file, blank pages found, page sizes normalized, pages cropped.' },
  export:   { icon: '⬇', label: 'Export',
              blurb: 'The assembled PDF, a flattened copy, page images, or the print dialog.' },
  protect:  { icon: '🔒', label: 'Protect',
              blurb: 'Document policy inside the Hub. Not encryption — read the line on the screen.' },
};

// Extraction kinds this module knows how to read a page for, and the LEON
// records each one would become. The mapping is declared here rather than
// inside the panel so a person reviewing an extraction can be shown, before
// anything is created, exactly which field lands where.
const PDFTOOL_EXTRACT_KINDS = ['Vendor Quote', 'Invoice', 'Packing List'];

// A field pattern is a set of LABELS as they actually appear on paper plus the
// shape of the value that follows. This is a labelled-value reader, not a
// model: if the label is not on the page, the field comes back empty and says
// so — it is never guessed from position or from what "usually" goes there.
const PDFTOOL_FIELD_PATTERNS = {
  'Vendor Quote': [
    { key: 'quoteNumber', label: 'Quote number', kind: 'text',
      labels: ['quotation no', 'quotation number', 'quote no', 'quote number', 'quote #', 'quotation #', 'offer no', 'ref no'],
      leon: 'Vendor Estimate → description reference' },
    { key: 'vendorName', label: 'Vendor', kind: 'text',
      labels: ['vendor', 'supplier', 'from', 'sold by', 'company'], leon: 'Vendor Estimate → vendorName' },
    { key: 'date', label: 'Quote date', kind: 'date',
      labels: ['date', 'quotation date', 'quote date', 'issued'], leon: 'Vendor Estimate → date' },
    { key: 'currency', label: 'Currency', kind: 'currency',
      labels: ['currency', 'ccy'], leon: 'Vendor Estimate → currency' },
    { key: 'amount', label: 'Total', kind: 'money',
      labels: ['grand total', 'total amount', 'total due', 'total', 'amount'], leon: 'Vendor Estimate → amount' },
  ],
  Invoice: [
    { key: 'invoiceNumber', label: 'Invoice number', kind: 'text',
      labels: ['invoice no', 'invoice number', 'invoice #', 'inv no', 'bill no', 'document no'],
      leon: 'AP Invoice → invoiceNumber' },
    { key: 'vendorName', label: 'Vendor', kind: 'text',
      labels: ['vendor', 'supplier', 'from', 'billed by', 'remit to'], leon: 'AP Invoice → vendorName' },
    { key: 'invoiceDate', label: 'Invoice date', kind: 'date',
      labels: ['invoice date', 'date of invoice', 'date', 'issued'], leon: 'AP Invoice → invoiceDate' },
    { key: 'dueDate', label: 'Due date', kind: 'date',
      labels: ['due date', 'payment due', 'due'], leon: 'AP Invoice → dueDate' },
    { key: 'poReference', label: 'PO reference', kind: 'text',
      labels: ['po no', 'po number', 'p.o.', 'purchase order', 'order no'], leon: 'AP Invoice → poReference' },
    { key: 'amount', label: 'Amount', kind: 'money',
      labels: ['grand total', 'total due', 'amount due', 'balance due', 'total', 'amount'], leon: 'AP Invoice → amount' },
  ],
  'Packing List': [
    { key: 'reference', label: 'Packing list no.', kind: 'text',
      labels: ['packing list no', 'packing list', 'pl no', 'delivery note no', 'shipment no'],
      leon: 'Packing List → reference' },
    { key: 'packages', label: 'Packages', kind: 'number',
      labels: ['packages', 'no of packages', 'total packages', 'cartons', 'crates', 'pallets'],
      leon: 'Packing List → package count' },
    { key: 'items', label: 'Line items', kind: 'number',
      labels: ['items', 'total items', 'no of items', 'line items'], leon: 'Packing List → line count' },
    { key: 'quantity', label: 'Total quantity', kind: 'number',
      labels: ['total qty', 'total quantity', 'qty', 'quantity'], leon: 'Packing List → quantity' },
    { key: 'weight', label: 'Weight', kind: 'text',
      labels: ['gross weight', 'net weight', 'total weight', 'weight', 'kgs', 'kg'], leon: 'Packing List → weight' },
  ],
};

// Where a reviewed extraction can be sent. `creator` names the ctx function
// that genuinely creates the record; a module with none is not hidden — it is
// listed with `creator: null`, and the UI says the hand-off is a link rather
// than pretending a record appeared somewhere.
const PDFTOOL_SEND_TARGETS = [
  { key: 'procurement', label: 'Procurement', creator: 'addVendorEstimate',
    makes: 'a Vendor Estimate on the project', kinds: ['Vendor Quote'] },
  { key: 'financial', label: 'Financial', creator: 'addApInvoice',
    makes: 'an AP Invoice on the project', kinds: ['Invoice', 'Vendor Quote'] },
  { key: 'drawing', label: 'Drawing Intelligence', creator: 'addTakeOff',
    makes: 'a Take-Off record on the project', kinds: ['Vendor Quote', 'Packing List'] },
  { key: 'logistics', label: 'Logistics', creator: null,
    makes: 'nothing on its own — a packing list is generated from a warehouse release, not from a page',
    kinds: ['Packing List'] },
  { key: 'installation', label: 'Installation', creator: null,
    makes: 'nothing on its own — an installation record is booked against a scope and a crew',
    kinds: ['Packing List', 'Invoice'] },
];

// Optimize presets. Every one of these works the same way — the pages are
// re-rendered at a DPI and re-encoded as JPEG — because that is the only
// size reduction a browser can actually perform. Nothing here re-samples an
// embedded image in place or subsets a font; claiming it did would be easy and
// wrong, so each preset says what it costs.
const PDFTOOL_OPTIMIZE_PRESETS = [
  { key: 'standard', label: 'Standard', dpi: 150, quality: 0.82,
    note: 'Readable on screen and fine to print at letter size.' },
  { key: 'high', label: 'High quality', dpi: 220, quality: 0.92,
    note: 'Keeps fine line work legible. Smaller, but not by much.' },
  { key: 'email', label: 'Email', dpi: 100, quality: 0.66,
    note: 'For sending. Small type will soften — check before it goes out.' },
  { key: 'custom', label: 'Custom', dpi: 150, quality: 0.8, note: 'Choose the DPI and quality yourself.' },
];

const PDFTOOL_PAGE_SIZES = {
  Letter: { w: 612, h: 792, label: 'Letter · 8.5 × 11 in' },
  A4: { w: 595.28, h: 841.89, label: 'A4 · 210 × 297 mm' },
  Legal: { w: 612, h: 1008, label: 'Legal · 8.5 × 14 in' },
};

const PDFTOOL_WM_POSITIONS = ['Center', 'Diagonal', 'Top', 'Bottom', 'Left', 'Right'];
const PDFTOOL_STAMP_POSITIONS = ['Top left', 'Top right', 'Bottom left', 'Bottom right', 'Center'];

// ═══════════════════════════════════════════════════════════════ small utils

function officePdfToolClamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }

// "1-3, 7, 9-" → [0,1,2,6,8,...]. One-based going in because that is what is
// printed on the page; zero-based coming out because that is what the plan uses.
function officePdfToolParseRanges(spec, max) {
  const out = [];
  String(spec || '').split(/[,;]/).forEach(part => {
    const t = part.trim();
    if (!t) return;
    const m = t.match(/^(\d+)?\s*(?:-|–|to)\s*(\d+)?$/i);
    if (m) {
      const a = m[1] ? parseInt(m[1], 10) : 1;
      const b = m[2] ? parseInt(m[2], 10) : max;
      for (let i = Math.min(a, b); i <= Math.max(a, b); i++) if (i >= 1 && i <= max) out.push(i - 1);
      return;
    }
    const n = parseInt(t, 10);
    if (!isNaN(n) && n >= 1 && n <= max) out.push(n - 1);
  });
  return out.filter((v, i, a) => a.indexOf(v) === i).sort((x, y) => x - y);
}
// The same thing back out again, so a selection can be shown as "1-4, 9".
function officePdfToolRangeLabel(indices) {
  if (!indices || !indices.length) return '—';
  const s = indices.slice().sort((a, b) => a - b);
  const parts = [];
  let start = s[0], prev = s[0];
  for (let i = 1; i <= s.length; i++) {
    if (i < s.length && s[i] === prev + 1) { prev = s[i]; continue; }
    parts.push(start === prev ? String(start + 1) : (start + 1) + '-' + (prev + 1));
    start = s[i]; prev = s[i];
  }
  return parts.join(', ');
}

// pdf-lib's standard fonts are WinAnsi-encoded and THROW on a character they
// cannot encode — an em dash pasted from a spec sheet is enough to fail a whole
// export. Substituting the near-equivalent is better than losing the file, and
// better than silently dropping the character.
function officePdfToolAscii(s) {
  return String(s == null ? '' : s)
    .replace(/[‘’‚′]/g, "'")
    .replace(/[“”„″]/g, '"')
    .replace(/[–—−]/g, '-')
    .replace(/[…]/g, '...')
    .replace(/[   ]/g, ' ')
    .replace(/[^\x20-\x7E\xA1-\xFF]/g, '');
}

function officePdfToolSafeName(s, fallback) {
  const t = String(s || '').replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, ' ').trim();
  return t || (fallback || 'document');
}

function officePdfToolPct(n) { return (n * 100).toFixed(1) + '%'; }

// The page plan the tools work from. `body.pages` is the working order; a
// document that has only just been imported may not have one yet, so this
// synthesizes the obvious plan rather than making every tool handle the gap.
function officePdfToolPlan(body) {
  const b = body || {};
  const pages = (b.pages || []).filter(p => p && p.assetId && !p.hidden);
  if (pages.length) {
    return pages.map(p => ({ id: p.id, assetId: p.assetId, index: p.index, rotate: p.rotate || 0,
                             crop: p.crop || null, label: p.label || '', sourceRef: p.sourceRef || null }));
  }
  if (!b.originalAssetId || !b.pageCount) return [];
  const out = [];
  for (let i = 0; i < b.pageCount; i++) {
    out.push({ id: null, assetId: b.originalAssetId, index: i, rotate: 0, crop: null, label: '', sourceRef: null });
  }
  return out;
}

// The document's own page list, materialised. A document that has never been
// reordered has no `pages` array, so the tools that EDIT the order need one to
// edit; this is where it comes from, and it is the only place that decision is
// made.
function officePdfToolBasePages(body, plan) {
  if (body && body.pages && body.pages.length) return body.pages;
  return (plan || []).map(p => makePdfPage(p.assetId, p.index, { rotate: p.rotate || 0, crop: p.crop || null,
                                                                 sourceRef: p.sourceRef || null }));
}
// Remove pages by their POSITION IN THE PLAN. The plan skips hidden pages, so
// plan position n is not body.pages position n the moment anything is hidden —
// matching on the page's own id is what stops the wrong page being dropped.
function officePdfToolWithout(body, plan, planIndices) {
  const base = officePdfToolBasePages(body, plan);
  const ids = new Set((planIndices || []).map(i => plan[i] && plan[i].id).filter(Boolean));
  if (ids.size) return base.filter(p => !ids.has(p.id));
  const drop = new Set(planIndices || []);
  return base.filter((p, i) => !drop.has(i));
}

// Tokens available to a header, footer or divider template. Kept as one map so
// the same {project} means the same thing on a footer, a divider and a cover.
function officePdfToolTokens(ctx, doc, extra) {
  const project = doc && doc.projectId ? (ctx.projects || []).find(p => p.id === doc.projectId) : null;
  const account = project ? (ctx.accounts || []).find(a => a.id === project.accountId) : null;
  const body = (doc && doc.body) || {};
  return {
    project: project ? project.name : '',
    projectNumber: project ? (project.projectNumber || '') : '',
    client: account ? account.name : '',
    address: project ? (project.address || '') : '',
    document: doc ? doc.name : '',
    number: body.documentNumber || '',
    category: body.category || '',
    revision: doc ? String(doc.revision || 0) : '',
    status: doc ? (doc.status || '') : '',
    date: typeof fmtDate === 'function' ? fmtDate(todayISO()) : todayISO(),
    company: (ctx.companyProfile && ctx.companyProfile.name) || 'LEON Integra',
    user: ctx.currentUserName || '',
    ...(extra || {}),
  };
}
function officePdfToolFillTokens(tpl, tokens, pageNo, pageTotal) {
  return String(tpl || '').replace(/\{(\w+)\}/g, (m, k) => {
    if (k === 'page') return String(pageNo == null ? '' : pageNo);
    if (k === 'pages') return String(pageTotal == null ? '' : pageTotal);
    return tokens[k] !== undefined ? String(tokens[k]) : m;
  });
}

// ── The audit trail ───────────────────────────────────────────────────────
// Two records, deliberately, because they answer two different questions.
// `doc.activity` answers "what happened to this file"; the project change log
// answers "what happened on this job". A packaging run belongs in both.
//
// Both go through officePdfToolWrite (below), which is the single write path
// for everything in this file — see the note on it for why that matters.
function officePdfToolLog(ctx, doc, text) {
  if (!doc || !text) return;
  const entry = { id: uid('act'), date: todayISO(), by: ctx.currentUserName || '', action: text };
  officePdfToolWrite(ctx, doc, officePdfToolOnChange(doc), d => ({
    ...d, activity: [entry, ...(d.activity || [])],
    modifiedBy: ctx.currentUserName || d.modifiedBy, modifiedDate: todayISO(),
  }));
  // Where the document belongs to a job, the job's own log has to carry it too
  // — someone auditing a project will not think to open a PDF's activity tab.
  if (doc.projectId && typeof ctx.updateProject === 'function' && typeof ctx.logAction === 'function') {
    ctx.updateProject(doc.projectId, draft => {
      ctx.logAction(draft, `LEON PDF — ${doc.name}: ${text}`);
    });
  }
}

// A tool changes the PDF BODY. The viewer owns the document record, and the
// onChange it hands down takes a WHOLE document — the same shape its own
// commit() writes — so the merge happens here rather than at twenty call sites.
// Handing back a bare body fragment was the alternative and it is a trap: the
// caller would replace the entire record with whichever two fields a tool
// happened to change.
//
// `meta` is for the few fields that live on the document rather than in its
// body (a rename, a category).
//
// This deliberately does NOT push onto the viewer's undo stack. A tool does not
// need one: nothing here overwrites an asset, so undoing a build means going
// back to the file that is still sitting in the store, and undoing a page-order
// change is re-adopting the previous plan.
function officePdfToolPatch(ctx, doc, onChange, bodyPatch, meta) {
  officePdfToolWrite(ctx, doc, onChange, d => ({
    ...d,
    ...(meta || {}),
    body: { ...(d.body || makePdfBody()), ...(bodyPatch || {}) },
    modifiedBy: (ctx && ctx.currentUserName) || d.modifiedBy,
    modifiedDate: todayISO(),
  }));
}

// ONE write path, and it goes through the onChange the viewer hands down.
//
// It is tempting to write straight to ctx.setOfficeDocs with a functional
// updater instead — officeDocs IS the persisted store, and functional updaters
// compose where two writes from one stale prop do not. That was the first
// design and it silently lost work. OfficeDocShell (office-sheets.jsx) holds
// the OPEN document in its own `local` state and resyncs from ctx only when a
// different document is opened, so a write that goes round it updates the store
// while the shell keeps the pre-edit copy — and the shell's next autosave, 700
// ms later, writes that copy back over it. An applied redaction would simply
// disappear.
//
// The composition problem is real, so it is solved directly: officePdfToolWrite
// chains consecutive writes through officePdfToolLatest instead of each one
// rebuilding from the stale prop.
// The most recent document this panel wrote, by id. It exists for one reason:
// a single action here often writes twice — the new page order AND the audit
// line — and the second call would otherwise rebuild from the stale `doc` prop
// and silently discard the first. Chaining through this ref makes consecutive
// writes compose within one tick, without going behind the shell's back.
const officePdfToolLatest = {};

// The log helper is called from deep inside async handlers that do not carry the
// panel's props, so it cannot pass onChange itself. It reads the one recorded
// here by the last real write. Passing null instead — which is what it used to
// do — routed the audit line round the document shell, and the shell's next
// autosave erased it.
function officePdfToolOnChange(doc) {
  const c = doc && officePdfToolLatest[doc.id];
  return c && typeof c.onChange === 'function' ? c.onChange : null;
}

function officePdfToolWrite(ctx, doc, onChange, mutate) {
  if (!doc || typeof mutate !== 'function') return;
  // Only chain within a short window. Beyond it the cached copy could be older
  // than what the shell now holds — a reopened document, or an edit made in the
  // viewer — and reviving it would undo that.
  const cached = officePdfToolLatest[doc.id];
  const fresh = cached && (performance.now() - cached.at) < 1500;
  const next = mutate(fresh ? cached.doc : doc);
  officePdfToolLatest[doc.id] = { doc: next, at: performance.now(),
    onChange: typeof onChange === 'function' ? onChange : (cached && cached.onChange) || null };
  // onChange FIRST, always, when it exists. The document shell (OfficeDocShell
  // in office-sheets.jsx) holds the open document in its own `local` state and
  // only resyncs from ctx when a DIFFERENT document is opened — so writing
  // straight to ctx.setOfficeDocs updates the store while the shell keeps the
  // pre-edit copy, and the shell's next autosave writes that copy back over
  // this one. A redaction applied here would simply vanish on the next
  // keystroke in the viewer. Going through onChange keeps one owner of truth.
  if (typeof onChange === 'function') { onChange(next); return; }
  if (ctx && typeof ctx.setOfficeDocs === 'function') {
    ctx.setOfficeDocs(prev => (prev || []).map(d => d.id === doc.id ? next : d));
  }
}

// ═════════════════════════════════════════════════════════ jobs and progress
// Every operation below runs in this tab. A 300-page rasterize is a minute of
// this machine's CPU, and a progress bar that is honest about that is the
// difference between "it is working" and "it has frozen". makePdfJob is the
// record shape; this hook is the running instance of it.
function useOfficePdfToolJob() {
  const [job, setJob] = useState(null);
  const last = useRef(null);
  const run = useCallback((kind, message, fn) => {
    last.current = { kind, message, fn };
    const j = makePdfJob({ kind, status: 'Processing', message, progress: 0 });
    setJob(j);
    const onProgress = (done, total) => setJob(prev => (prev && prev.id === j.id)
      ? { ...prev, progress: total ? done / total : 0, message: `${message} — ${done} of ${total}` } : prev);
    return Promise.resolve()
      .then(() => fn(onProgress))
      .then(result => {
        setJob(prev => (prev && prev.id === j.id)
          ? { ...prev, status: 'Completed', progress: 1, message: 'Finished', finishedDate: todayISO() } : prev);
        return result;
      })
      .catch(err => {
        setJob(prev => (prev && prev.id === j.id)
          ? { ...prev, status: 'Failed', message: 'Could not finish', finishedDate: todayISO(),
              error: (err && err.message) || String(err) } : prev);
        return null;
      });
  }, []);
  const retry = useCallback(() => {
    const l = last.current;
    if (l) run(l.kind, l.message, l.fn);
  }, [run]);
  return { job, run, retry, clear: () => setJob(null) };
}

function OfficePdfToolJobBar({ job, onRetry, onClear }) {
  if (!job) return null;
  const tone = job.status === 'Failed' ? 'red' : job.status === 'Completed' ? 'green'
    : job.status === 'Processing' ? 'blue' : 'neutral';
  const pct = Math.round((job.progress || 0) * 100);
  return (
    <div className="rounded-lg border border-[var(--leon-line)] bg-white px-3 py-2.5 space-y-1.5">
      <div className="flex items-center gap-2 flex-wrap">
        <Badge tone={tone}>{job.status}</Badge>
        <span className="text-xs text-[var(--leon-black)]/70">{job.message || job.kind}</span>
        <span className="ml-auto flex items-center gap-1.5">
          {job.status === 'Failed' && onRetry && <Button size="sm" variant="outline" onClick={onRetry}>Try again</Button>}
          {job.status !== 'Processing' && onClear && <Button size="sm" variant="ghost" onClick={onClear}>Dismiss</Button>}
        </span>
      </div>
      {job.status === 'Processing' && (
        <div className="h-1.5 rounded-full bg-[var(--leon-line)] overflow-hidden">
          <div className="h-full bg-[var(--leon-brown)] transition-all" style={{ width: pct + '%' }} />
        </div>
      )}
      {job.error && <p className="text-[11px] text-[var(--leon-red)]">{job.error}</p>}
      {job.status === 'Processing' && (
        <p className="text-[11px] text-[var(--leon-black)]/45">
          This runs on this computer, not on a server — a very large document depends on this machine, and
          leaving the tab is what stops it.
        </p>
      )}
    </div>
  );
}

// ── The honesty primitives ────────────────────────────────────────────────
function OfficePdfToolNote({ children, tone }) {
  const tones = {
    warn: 'border-[#e0c07a] bg-[#fdf7ea] text-[#7a5c12]',
    info: 'border-[var(--leon-line)] bg-[var(--leon-cream)]/70 text-[var(--leon-black)]/70',
    danger: 'border-[#e8b8b8] bg-[#fdf1f1] text-[#8d3232]',
  };
  return (
    <p className={`rounded-md border px-3 py-2 text-[11.5px] leading-relaxed ${tones[tone] || tones.info}`}>
      {children}
    </p>
  );
}
// The AI control, in the shape studio.jsx settled on. The Hub still runs no AI
// service — it cannot from a page with no backend — so this writes the request
// and opens the assistant that can answer it, and says exactly that.
function OfficePdfToolAiButton({ label, would, docName }) {
  const brief = buildAiHandoffBrief({
    task: 'I need help with this: ' + label + '.',
    detail: 'The intent is to ' + would,
    context: [{ label: 'Raised from', value: 'LEON PDF' }, { label: 'Document', value: docName || '' }],
    files: [docName ? 'The PDF: ' + docName : 'The PDF this concerns'],
  });
  return (
    <div className="rounded-md border border-dashed border-[var(--leon-line)] px-3 py-2">
      <div className="flex items-center gap-2">
        <AiHandoffButton label={label} brief={brief} title={label} />
      </div>
      <p className="text-[11px] text-[var(--leon-black)]/50 mt-1 leading-snug">
        Would {would} The Hub runs no AI service, so this writes the request and opens Claude or
        ChatGPT — attach the PDF there and bring the answer back.
      </p>
    </div>
  );
}

function OfficePdfToolHeader({ tool, right }) {
  const t = PDFTOOL_TOOLS[tool] || {};
  return (
    <div className="flex items-start justify-between gap-3 flex-wrap">
      <div>
        <h3 className="font-bold text-base flex items-center gap-2">
          <span aria-hidden="true">{t.icon}</span>{t.label || tool}
        </h3>
        {t.blurb && <p className="text-xs text-[var(--leon-black)]/55 mt-0.5">{t.blurb}</p>}
      </div>
      {right}
    </div>
  );
}

// ── Page thumbnails ───────────────────────────────────────────────────────
// Rendered to a data URL and shown in an <img>, never left as a live <canvas>.
// cloneNode does not copy a canvas bitmap, so a canvas prints and exports to
// PDF as an empty rectangle — the same trap studio.jsx documented.
function OfficePdfToolThumb({ assetId, index, rotate, width, className, onClick, selected, caption }) {
  const [url, setUrl] = useState(null);
  const [err, setErr] = useState(null);
  const w = width || 108;
  useEffect(() => {
    let live = true;
    setUrl(null); setErr(null);
    if (!assetId && assetId !== 0) return undefined;
    officePdfRenderPage(assetId, index, { width: w, dpr: 1, rotate: rotate || 0 })
      .then(r => { if (live) setUrl(r.canvas.toDataURL('image/jpeg', 0.72)); })
      .catch(e => { if (live) setErr((e && e.message) || 'Could not render'); });
    return () => { live = false; };
  }, [assetId, index, rotate, w]);
  return (
    <div className={`shrink-0 ${className || ''}`}>
      <div
        onClick={onClick}
        className={`rounded border bg-white overflow-hidden flex items-center justify-center ${onClick ? 'cursor-pointer' : ''} ${
          selected ? 'border-[var(--leon-brown)] ring-2 ring-[var(--leon-brown)]/30' : 'border-[var(--leon-line)]'}`}
        style={{ width: w, height: Math.round(w * 1.294) }}>
        {url ? <img src={url} alt="" className="max-w-full max-h-full" />
          : <span className="text-[10px] text-[var(--leon-black)]/35 px-1 text-center">{err || '…'}</span>}
      </div>
      {caption && <div className="text-[10px] text-center text-[var(--leon-black)]/50 mt-0.5 truncate" style={{ width: w }}>{caption}</div>}
    </div>
  );
}

// ── Writing a result out ──────────────────────────────────────────────────
// Every build ends here: bytes become a NEW asset, and the panel gets back
// enough to download it, print it, or point the document at it. Nothing in this
// file ever calls officePdfDeleteAsset on a source.
function officePdfToolSaveResult(bytes, name, meta) {
  const buf = bytes.buffer ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) : bytes;
  return officePdfPutAsset(buf, { name, kind: 'pdf', mime: 'application/pdf', ...(meta || {}) })
    .then(assetId => officePdfDocument(assetId).then(pdf => ({
      assetId, name, size: bytes.byteLength || bytes.length || 0, pageCount: pdf.numPages, bytes,
    })));
}

function OfficePdfToolResult({ result, onAdopt, adoptLabel, children }) {
  if (!result) return null;
  return (
    <div className="rounded-lg border border-[#bcd9c2] bg-[#f3faf4] px-3 py-3 space-y-2">
      <div className="flex items-center gap-2 flex-wrap">
        <Badge tone="green">Written</Badge>
        <span className="text-sm font-semibold">{result.name}</span>
        <span className="text-xs text-[var(--leon-black)]/55">
          {result.pageCount} page{result.pageCount === 1 ? '' : 's'} · {officePdfBytesLabel(result.size)}
        </span>
      </div>
      <div className="flex items-center gap-2 flex-wrap">
        <Button size="sm" onClick={() => officePdfDownload(result.bytes, result.name)}>⬇ Download</Button>
        <Button size="sm" variant="outline" onClick={() => officePdfPrint(result.bytes)}>🖨 Print</Button>
        {onAdopt && <Button size="sm" variant="black" onClick={onAdopt}>{adoptLabel || 'Use as this document'}</Button>}
      </div>
      {children}
      <p className="text-[11px] text-[var(--leon-black)]/45">
        This is a new file in the document store. The original is untouched and still there.
      </p>
    </div>
  );
}

// ═══════════════════════════════════════════════ generated pages (pdf-lib)
// Covers, dividers and contents pages are real PDF pages written from scratch,
// stored as their own asset and then dropped into the plan like any other
// source. That is what makes a package a single assembled document rather than
// a viewer trick — the divider is a page, and it prints as one.
function officePdfToolMakeTextPages(specs, opts) {
  const o = opts || {};
  const size = PDFTOOL_PAGE_SIZES[o.pageSize] || PDFTOOL_PAGE_SIZES.Letter;
  return officePdfLibs().then(({ PDFLib }) => PDFLib.PDFDocument.create().then(out =>
    Promise.all([
      out.embedFont(PDFLib.StandardFonts.HelveticaBold),
      out.embedFont(PDFLib.StandardFonts.Helvetica),
    ]).then(([bold, plain]) => {
      const ink = PDFLib.rgb(0.086, 0.075, 0.067);
      const brown = PDFLib.rgb(0.42, 0.31, 0.22);
      const faint = PDFLib.rgb(0.62, 0.60, 0.58);
      specs.forEach(spec => {
        const page = out.addPage([size.w, size.h]);
        const M = 64;
        let y = size.h - M - 24;
        if (spec.eyebrow) {
          page.drawText(officePdfToolAscii(spec.eyebrow).toUpperCase(), {
            x: M, y, size: 9, font: bold, color: brown });
          y -= 26;
        }
        (officePdfToolWrap(spec.title || '', bold, 26, size.w - M * 2)).forEach(line => {
          page.drawText(officePdfToolAscii(line), { x: M, y, size: 26, font: bold, color: ink });
          y -= 32;
        });
        page.drawRectangle({ x: M, y: y - 4, width: size.w - M * 2, height: 1.4, color: brown });
        y -= 30;
        if (spec.subtitle) {
          officePdfToolWrap(spec.subtitle, plain, 12, size.w - M * 2).forEach(line => {
            page.drawText(officePdfToolAscii(line), { x: M, y, size: 12, font: plain, color: ink });
            y -= 17;
          });
          y -= 8;
        }
        (spec.lines || []).forEach(l => {
          if (y < M + 40) return;   // a generated page never spills onto a second one silently
          if (l && l.label !== undefined) {
            page.drawText(officePdfToolAscii(l.label).toUpperCase(), { x: M, y, size: 8, font: bold, color: faint });
            const wrapped = officePdfToolWrap(String(l.value == null ? '' : l.value), plain, 11, size.w - M * 2 - 160);
            wrapped.forEach((line, i) => {
              page.drawText(officePdfToolAscii(line), { x: M + 160, y: y - i * 14, size: 11, font: plain, color: ink });
            });
            y -= 14 * (wrapped.length || 1) + 6;
          } else {
            officePdfToolWrap(String(l == null ? '' : l), plain, 11, size.w - M * 2).forEach(line => {
              page.drawText(officePdfToolAscii(line), { x: M, y, size: 11, font: plain, color: ink });
              y -= 15;
            });
          }
        });
        if (spec.footer) {
          page.drawText(officePdfToolAscii(spec.footer), { x: M, y: M - 18, size: 8, font: plain, color: faint });
        }
      });
      return out;
    })
  ));
}
// Greedy wrap against the real font metrics, so a long project name breaks
// where it actually runs out of paper rather than where it looked like it might.
function officePdfToolWrap(text, font, size, maxWidth) {
  const clean = officePdfToolAscii(text);
  if (!clean) return [];
  const words = clean.split(/\s+/);
  const lines = [];
  let line = '';
  words.forEach(w => {
    const test = line ? line + ' ' + w : w;
    let width = 0;
    try { width = font.widthOfTextAtSize(test, size); } catch (e) { width = test.length * size * 0.5; }
    if (width > maxWidth && line) { lines.push(line); line = w; } else { line = test; }
  });
  if (line) lines.push(line);
  return lines;
}

// ══════════════════════════════════════════════ pulling LEON files into a PDF
// A LEON record's attachment is stored as a data URL by FileField, which means
// the bytes are genuinely here and a PDF or an image among them can be merged
// for real. Anything that is only a FILE NAME — a drawing set logged by name, a
// PO with no upload — has no bytes to merge, and is listed on the divider as
// included by reference instead of being quietly dropped.
function officePdfToolFileFromDataUrl(url, name) {
  // fetch() on a data: URL decodes it locally and touches no network, which is
  // both simpler and safer than hand-rolling base64 for every mime type.
  return fetch(url).then(r => r.blob()).then(b => new File([b], name || 'file', { type: b.type }));
}
function officePdfToolResolveAttachment(item) {
  const url = item && item.fileUrl;
  const name = (item && item.name) || 'file';
  if (!url || !/^data:/i.test(url)) return Promise.resolve(null);
  return officePdfToolFileFromDataUrl(url, name).then(file => {
    const isPdf = /pdf/i.test(file.type) || /\.pdf$/i.test(name);
    const isImg = /^image\//i.test(file.type) || /\.(png|jpe?g)$/i.test(name);
    if (isPdf) return officePdfImportFile(file);
    if (isImg) {
      return officePdfFromImages([file], { pageSize: 'Letter' })
        .then(d => officePdfSaveDoc(d))
        .then(bytes => officePdfToolSaveResult(bytes, officePdfToolSafeName(name) + '.pdf', { kind: 'pdf' }));
    }
    return null;
  }).catch(() => null);
}

// Every LEON PDF document that already has bytes in the store — the pool the
// combine and package pickers draw from.
function officePdfToolPdfDocs(ctx, excludeId) {
  return (ctx.officeDocs || []).filter(d =>
    d.app === 'pdf' && !d.archived && d.id !== excludeId &&
    d.body && (d.body.workingAssetId || d.body.originalAssetId));
}
function officePdfToolDocAsset(d) {
  return (d.body && (d.body.workingAssetId || d.body.originalAssetId)) || null;
}

// ═══════════════════════════════════════════════════════════════════ COMBINE
// Sources in an order, one file out. Each page carries a sourceRef so the
// combined document still knows what each section came from — without it, the
// only thing a merged binder remembers is that it used to be several files.
function OfficePdfToolCombine({ ctx, doc, onChange, editable }) {
  const [sources, setSources] = useState(() => {
    const plan = officePdfToolPlan(doc && doc.body);
    if (!plan.length) return [];
    return [{ id: uid('src'), kind: 'self', name: doc.name, docId: doc.id,
              assetId: null, plan, pageCount: plan.length }];
  });
  const [pick, setPick] = useState(false);
  const [dragId, setDragId] = useState(null);
  const [result, setResult] = useState(null);
  const { job, run, retry, clear } = useOfficePdfToolJob();
  const pdfInput = useRef(null);
  const imgInput = useRef(null);

  const total = sources.reduce((s, x) => s + (x.pageCount || 0), 0);

  function addPdfs(files) {
    if (!files || !files.length) return;
    run('combine', 'Reading files', () =>
      Array.from(files).reduce((chain, f, i) => chain.then(acc =>
        officePdfImportFile(f).then(r => {
          acc.push({ id: uid('src'), kind: 'pdf', name: f.name, assetId: r.assetId, pageCount: r.pageCount });
          return acc;
        })
      ), Promise.resolve([])).then(added => { setSources(prev => prev.concat(added)); return added; })
    );
  }
  function addImages(files) {
    if (!files || !files.length) return;
    run('combine', 'Converting images', onProgress =>
      officePdfFromImages(files, { onProgress })
        .then(d => officePdfSaveDoc(d))
        .then(bytes => officePdfToolSaveResult(bytes, 'images.pdf', { kind: 'pdf' }))
        .then(r => setSources(prev => prev.concat([{
          id: uid('src'), kind: 'image', name: `${files.length} image${files.length === 1 ? '' : 's'}`,
          assetId: r.assetId, pageCount: r.pageCount }])))
    );
  }
  function addLeonDoc(d) {
    const assetId = officePdfToolDocAsset(d);
    if (!assetId) return;
    officePdfDocument(assetId).then(pdf => {
      setSources(prev => prev.concat([{ id: uid('src'), kind: 'leon', name: d.name, docId: d.id,
                                        assetId, pageCount: pdf.numPages }]));
    });
    setPick(false);
  }
  function move(id, dir) {
    setSources(prev => {
      const i = prev.findIndex(s => s.id === id);
      const j = i + dir;
      if (i < 0 || j < 0 || j >= prev.length) return prev;
      const next = prev.slice();
      next.splice(j, 0, next.splice(i, 1)[0]);
      return next;
    });
  }
  function dropOn(id) {
    if (!dragId || dragId === id) return;
    setSources(prev => {
      const from = prev.findIndex(s => s.id === dragId);
      const to = prev.findIndex(s => s.id === id);
      if (from < 0 || to < 0) return prev;
      const next = prev.slice();
      next.splice(to, 0, next.splice(from, 1)[0]);
      return next;
    });
    setDragId(null);
  }

  // The plan is built here rather than inside the assemble call so the exact
  // same array can be adopted as the document's page order — the file that was
  // written and the order the document now believes in are one thing.
  function buildPlan() {
    const plan = [];
    sources.forEach(s => {
      const ref = { kind: s.kind, docId: s.docId || null, name: s.name };
      if (s.kind === 'self' && s.plan) {
        s.plan.forEach(p => plan.push({ assetId: p.assetId, index: p.index, rotate: p.rotate, crop: p.crop,
                                        sourceRef: { ...ref, sourcePage: p.index + 1 } }));
      } else {
        for (let i = 0; i < s.pageCount; i++) {
          plan.push({ assetId: s.assetId, index: i, rotate: 0, crop: null,
                      sourceRef: { ...ref, sourcePage: i + 1 } });
        }
      }
    });
    return plan;
  }

  function build() {
    const plan = buildPlan();
    if (!plan.length) return;
    setResult(null);
    run('combine', 'Combining pages', onProgress =>
      officePdfAssemble(plan, { onProgress, title: doc.name, author: ctx.currentUserName || 'LEON Integra' })
        .then(out => officePdfSaveDoc(out))
        .then(bytes => officePdfToolSaveResult(bytes, officePdfToolSafeName(doc.name, 'combined') + ' — combined.pdf',
                                               { kind: 'pdf', from: 'combine' }))
        .then(r => { setResult(r); return r; })
    );
  }

  function adopt() {
    const plan = buildPlan();
    officePdfToolPatch(ctx, doc, onChange, {
      pages: plan.map(p => makePdfPage(p.assetId, p.index, { rotate: p.rotate, crop: p.crop, sourceRef: p.sourceRef })),
      pageCount: plan.length,
      workingAssetId: result ? result.assetId : null,
      sourceType: 'Combined',
    });
    officePdfToolLog(ctx, doc, `Combined ${sources.length} source${sources.length === 1 ? '' : 's'} into ${plan.length} pages.`);
  }

  return (
    <div className="space-y-3">
      <OfficePdfToolHeader tool="combine" />
      <div className="flex items-center gap-2 flex-wrap">
        <Button size="sm" variant="outline" disabled={!editable} onClick={() => pdfInput.current && pdfInput.current.click()}>+ PDFs</Button>
        <Button size="sm" variant="outline" disabled={!editable} onClick={() => imgInput.current && imgInput.current.click()}>+ Images</Button>
        <Button size="sm" variant="outline" disabled={!editable} onClick={() => setPick(true)}>+ LEON document</Button>
        <span className="text-xs text-[var(--leon-black)]/50 ml-auto">
          {sources.length} source{sources.length === 1 ? '' : 's'} · {total} page{total === 1 ? '' : 's'}
        </span>
        <input ref={pdfInput} type="file" accept="application/pdf,.pdf" multiple className="hidden"
          onChange={e => { addPdfs(e.target.files); e.target.value = ''; }} />
        <input ref={imgInput} type="file" accept="image/png,image/jpeg" multiple className="hidden"
          onChange={e => { addImages(e.target.files); e.target.value = ''; }} />
      </div>

      {!sources.length && <EmptyState text="Nothing to combine yet. Add PDFs, images or a LEON document." />}

      <ul className="space-y-1.5">
        {sources.map((s, i) => (
          <li key={s.id} draggable={editable}
            onDragStart={() => setDragId(s.id)}
            onDragOver={e => e.preventDefault()}
            onDrop={() => dropOn(s.id)}
            className={`flex items-center gap-2 rounded-lg border px-3 py-2 bg-white ${
              dragId === s.id ? 'border-[var(--leon-brown)]' : 'border-[var(--leon-line)]'}`}>
            <span className="text-[var(--leon-black)]/25 cursor-grab select-none" title="Drag to reorder">⠿</span>
            <span className="text-xs font-mono text-[var(--leon-black)]/40 w-5">{i + 1}</span>
            {s.assetId && <OfficePdfToolThumb assetId={s.assetId} index={0} width={34} />}
            <span className="flex-1 min-w-0">
              <span className="block text-sm font-semibold truncate">{s.name}</span>
              <span className="block text-[11px] text-[var(--leon-black)]/50">
                {s.kind === 'self' ? 'This document' : s.kind === 'leon' ? 'LEON document' : s.kind === 'image' ? 'Images' : 'Uploaded PDF'}
                {' · '}{s.pageCount} page{s.pageCount === 1 ? '' : 's'}
              </span>
            </span>
            <IconAction icon="↑" title="Move up" disabled={!editable || i === 0} onClick={() => move(s.id, -1)} />
            <IconAction icon="↓" title="Move down" disabled={!editable || i === sources.length - 1} onClick={() => move(s.id, 1)} />
            <IconAction icon="✕" title="Remove" disabled={!editable} onClick={() => setSources(prev => prev.filter(x => x.id !== s.id))} />
          </li>
        ))}
      </ul>

      <OfficePdfToolJobBar job={job} onRetry={retry} onClear={clear} />
      <div className="flex items-center gap-2">
        <Button size="sm" disabled={!editable || !sources.length} onClick={build}>Combine into one PDF</Button>
      </div>
      <OfficePdfToolResult result={result} onAdopt={editable ? adopt : null} adoptLabel="Make this the document's page order" />
      <OfficePdfToolNote>
        Each page in the result keeps a note of the file it came from, so a section can still be traced back after
        the binder is one document.
      </OfficePdfToolNote>

      <Modal open={pick} onClose={() => setPick(false)} wide title="Add a LEON document">
        <OfficePdfToolDocPicker ctx={ctx} excludeId={doc.id} onPick={addLeonDoc} />
      </Modal>
    </div>
  );
}

function OfficePdfToolDocPicker({ ctx, excludeId, onPick }) {
  const [q, setQ] = useState('');
  const list = officePdfToolPdfDocs(ctx, excludeId)
    .filter(d => !q || (d.name || '').toLowerCase().includes(q.toLowerCase()));
  return (
    <div className="space-y-2">
      <TextInput placeholder="Search documents…" value={q} onChange={e => setQ(e.target.value)} />
      {!list.length && <EmptyState text="No other LEON PDF documents have pages stored yet." />}
      <ul className="space-y-1 max-h-72 overflow-y-auto">
        {list.map(d => {
          const project = d.projectId ? (ctx.projects || []).find(p => p.id === d.projectId) : null;
          return (
            <li key={d.id}>
              <button type="button" onClick={() => onPick(d)}
                className="w-full text-left rounded-lg border border-[var(--leon-line)] px-3 py-2 hover:border-[var(--leon-brown)]">
                <span className="block text-sm font-semibold">{d.name}</span>
                <span className="block text-[11px] text-[var(--leon-black)]/50">
                  {(d.body && d.body.category) || 'Other'}{project ? ` · ${project.name}` : ''} · {(d.body && d.body.pageCount) || 0} pages
                </span>
              </button>
            </li>
          );
        })}
      </ul>
    </div>
  );
}

// ══════════════════════════════════════════════════════════ SPLIT / EXTRACT
// Four ways to cut a document, one result shape. Splitting never touches the
// source: each part is assembled out of the same immutable pages, so a split
// that was wrong costs nothing but the file it wrote.
const PDFTOOL_SPLIT_MODES = [
  { key: 'count', label: 'Every N pages' },
  { key: 'ranges', label: 'Manual ranges' },
  { key: 'selected', label: 'Selected pages' },
  { key: 'bookmark', label: 'At each bookmark' },
];

function OfficePdfToolSplit({ ctx, doc, onChange, editable }) {
  const body = (doc && doc.body) || {};
  const plan = useMemo(() => officePdfToolPlan(body), [body]);
  const [mode, setMode] = useState('count');
  const [every, setEvery] = useState(10);
  const [ranges, setRanges] = useState('1-5, 6-10');
  const [selected, setSelected] = useState([]);
  const [removeAfter, setRemoveAfter] = useState(false);
  const [asDocs, setAsDocs] = useState(false);
  const [results, setResults] = useState([]);
  const { job, run, retry, clear } = useOfficePdfToolJob();

  const bookmarks = (body.bookmarks || []).slice().sort((a, b) => (a.page || 0) - (b.page || 0));

  // One place decides what the parts are, so the preview and the build can
  // never describe different documents.
  const parts = useMemo(() => {
    const n = plan.length;
    if (!n) return [];
    if (mode === 'count') {
      const step = Math.max(1, parseInt(every, 10) || 1);
      const out = [];
      for (let i = 0; i < n; i += step) {
        const idx = [];
        for (let j = i; j < Math.min(n, i + step); j++) idx.push(j);
        out.push({ title: `Pages ${i + 1}-${Math.min(n, i + step)}`, indices: idx });
      }
      return out;
    }
    if (mode === 'ranges') {
      return String(ranges || '').split(/[,;]/).map(s => s.trim()).filter(Boolean).map(s => {
        const idx = officePdfToolParseRanges(s, n);
        return { title: `Pages ${officePdfToolRangeLabel(idx)}`, indices: idx };
      }).filter(p => p.indices.length);
    }
    if (mode === 'selected') {
      return selected.length ? [{ title: `Pages ${officePdfToolRangeLabel(selected)}`, indices: selected.slice().sort((a, b) => a - b) }] : [];
    }
    if (mode === 'bookmark') {
      if (!bookmarks.length) return [];
      const out = [];
      bookmarks.forEach((bm, i) => {
        const start = officePdfToolClamp(bm.page || 0, 0, n - 1);
        const end = i + 1 < bookmarks.length ? officePdfToolClamp(bookmarks[i + 1].page || n, 0, n) : n;
        const idx = [];
        for (let j = start; j < end; j++) idx.push(j);
        if (idx.length) out.push({ title: bm.title || `Section ${i + 1}`, indices: idx });
      });
      return out;
    }
    return [];
  }, [mode, every, ranges, selected, plan.length, body.bookmarks]);

  function build() {
    if (!parts.length) return;
    setResults([]);
    run('split', 'Writing parts', onProgress =>
      parts.reduce((chain, part, i) => chain.then(acc =>
        officePdfAssemble(part.indices.map(ix => plan[ix]), { title: `${doc.name} — ${part.title}` })
          .then(out => officePdfSaveDoc(out))
          .then(bytes => officePdfToolSaveResult(bytes,
            `${officePdfToolSafeName(doc.name, 'document')} — ${officePdfToolSafeName(part.title)}.pdf`,
            { kind: 'pdf', from: 'split' }))
          .then(r => { onProgress(i + 1, parts.length); acc.push({ ...r, title: part.title, indices: part.indices }); return acc; })
      ), Promise.resolve([])).then(list => {
        setResults(list);
        if (asDocs) createDocs(list);
        officePdfToolLog(ctx, doc, `Split into ${list.length} document${list.length === 1 ? '' : 's'} (${mode}).`);
        return list;
      })
    );
  }

  // A part can become a LEON document in its own right — which is usually the
  // point of splitting a hundred-page issue into the four scopes it covers.
  function createDocs(list) {
    if (typeof ctx.setOfficeDocs !== 'function') return;
    const made = list.map(r => makeOfficeDocument({
      name: `${doc.name} — ${r.title}`,
      app: 'pdf', projectId: doc.projectId || null, scopeId: doc.scopeId || null,
      folder: doc.folder, tags: (doc.tags || []).slice(),
      body: { ...makePdfBody(), originalAssetId: r.assetId, pageCount: r.pageCount,
              pages: Array.from({ length: r.pageCount }, (_, i) => makePdfPage(r.assetId, i)),
              sourceType: 'Extracted', sourceDocId: doc.id, category: (doc.body && doc.body.category) || 'Other' },
      activity: [{ id: uid('act'), date: todayISO(), by: ctx.currentUserName || '',
                   action: `Created by splitting "${doc.name}" (pages ${officePdfToolRangeLabel(r.indices)}).` }],
    }, ctx.currentUserName || ''));
    ctx.setOfficeDocs(prev => (prev || []).concat(made));
  }

  function extractSelected() {
    if (!selected.length) return;
    const idx = selected.slice().sort((a, b) => a - b);
    setResults([]);
    run('extract-pages', 'Extracting pages', onProgress =>
      officePdfAssemble(idx.map(i => plan[i]), { onProgress, title: `${doc.name} — extract` })
        .then(out => officePdfSaveDoc(out))
        .then(bytes => officePdfToolSaveResult(bytes,
          `${officePdfToolSafeName(doc.name, 'document')} — pages ${officePdfToolRangeLabel(idx)}.pdf`,
          { kind: 'pdf', from: 'extract' }))
        .then(r => {
          setResults([{ ...r, title: `Pages ${officePdfToolRangeLabel(idx)}`, indices: idx }]);
          if (asDocs) createDocs([{ ...r, title: `Pages ${officePdfToolRangeLabel(idx)}`, indices: idx }]);
          if (removeAfter) {
            // Removal is a change to the ORDER PLAN, not to any file. The pages
            // still exist in the source asset; the document has simply stopped
            // pointing at them, which is what makes it undoable.
            const keep = officePdfToolWithout(body, plan, idx);
            officePdfToolPatch(ctx, doc, onChange, { pages: keep, pageCount: keep.length });
          }
          officePdfToolLog(ctx, doc,
            `Extracted pages ${officePdfToolRangeLabel(idx)}${removeAfter ? ' and removed them from this document' : ''}.`);
          setSelected([]);
          return r;
        })
    );
  }

  return (
    <div className="space-y-3">
      <OfficePdfToolHeader tool="organize" />
      {!plan.length && <EmptyState text="This document has no pages yet." />}

      {!!plan.length && (
        <>
          <div className="flex flex-wrap gap-1.5">
            {PDFTOOL_SPLIT_MODES.map(m => (
              <button key={m.key} type="button" onClick={() => setMode(m.key)}
                className={`px-2.5 py-1 rounded-md text-xs font-semibold border ${
                  mode === m.key ? 'border-[var(--leon-brown)] text-[var(--leon-brown)] bg-[var(--leon-cream)]'
                    : 'border-[var(--leon-line)] text-[var(--leon-black)]/60'}`}>{m.label}</button>
            ))}
          </div>

          {mode === 'count' && (
            <Field label="Pages per document" hint={`${plan.length} pages → ${parts.length} document${parts.length === 1 ? '' : 's'}`}>
              <TextInput type="number" min="1" value={every} onChange={e => setEvery(e.target.value)} className="!w-32" />
            </Field>
          )}
          {mode === 'ranges' && (
            <Field label="Ranges" hint="One document per range. Page numbers as printed — 1-5, 6-10, 11-">
              <TextInput value={ranges} onChange={e => setRanges(e.target.value)} />
            </Field>
          )}
          {mode === 'bookmark' && (
            bookmarks.length
              ? <p className="text-xs text-[var(--leon-black)]/60">{bookmarks.length} bookmarks → {parts.length} sections.</p>
              : <OfficePdfToolNote tone="warn">This document has no bookmarks, so there is nothing to split at.</OfficePdfToolNote>
          )}
          {mode === 'selected' && (
            <div className="space-y-2">
              <div className="flex items-center gap-2 flex-wrap">
                <TextInput placeholder="Type pages, e.g. 1-3, 9" className="!w-56"
                  onChange={e => setSelected(officePdfToolParseRanges(e.target.value, plan.length))} />
                <span className="text-xs text-[var(--leon-black)]/55">Selected: {officePdfToolRangeLabel(selected)}</span>
                {!!selected.length && <Button size="sm" variant="ghost" onClick={() => setSelected([])}>Clear</Button>}
              </div>
              <div className="flex gap-2 overflow-x-auto pb-1">
                {plan.slice(0, 60).map((p, i) => (
                  <OfficePdfToolThumb key={i} assetId={p.assetId} index={p.index} rotate={p.rotate} width={62}
                    caption={String(i + 1)} selected={selected.indexOf(i) >= 0}
                    onClick={() => setSelected(prev => prev.indexOf(i) >= 0 ? prev.filter(x => x !== i) : prev.concat([i]))} />
                ))}
              </div>
              {plan.length > 60 && <p className="text-[11px] text-[var(--leon-black)]/45">First 60 pages shown — type a range above for the rest.</p>}
              <label className="flex items-center gap-2 text-xs">
                <input type="checkbox" checked={removeAfter} onChange={e => setRemoveAfter(e.target.checked)} />
                Remove these pages from this document's page order afterwards
              </label>
            </div>
          )}

          <label className="flex items-center gap-2 text-xs">
            <input type="checkbox" checked={asDocs} onChange={e => setAsDocs(e.target.checked)} />
            Also create each part as its own LEON PDF document
          </label>

          {mode !== 'selected' && !!parts.length && (
            <ul className="text-xs text-[var(--leon-black)]/60 space-y-0.5 max-h-32 overflow-y-auto">
              {parts.map((p, i) => <li key={i}>· {p.title} — {p.indices.length} page{p.indices.length === 1 ? '' : 's'}</li>)}
            </ul>
          )}

          <OfficePdfToolJobBar job={job} onRetry={retry} onClear={clear} />
          <div className="flex items-center gap-2">
            {mode === 'selected'
              ? <Button size="sm" disabled={!editable || !selected.length} onClick={extractSelected}>Extract {selected.length || ''} page{selected.length === 1 ? '' : 's'}</Button>
              : <Button size="sm" disabled={!editable || !parts.length} onClick={build}>Split into {parts.length} document{parts.length === 1 ? '' : 's'}</Button>}
          </div>

          {results.map(r => (
            <OfficePdfToolResult key={r.assetId} result={r}>
              <p className="text-[11px] text-[var(--leon-black)]/50">{r.title}</p>
            </OfficePdfToolResult>
          ))}
        </>
      )}
    </div>
  );
}

// ════════════════════════════════════════════════════ PACKAGE / BINDER BUILDER
// A package is an ORDER of sections, and every section is a list of items that
// each resolve — or fail to resolve — to real pages. The distinction that makes
// this honest is the one between an item whose bytes are here (a PDF or image
// uploaded onto a LEON record, a LEON PDF document, this document's own pages)
// and an item that exists only as a NAME on a record. The first is merged. The
// second is printed on the divider as "included by reference", because a binder
// that silently omitted the warranty letter is worse than one that says the
// warranty letter was never uploaded.

const PDFTOOL_PACKAGE_KINDS = [
  { key: 'blank', label: 'Empty package', blurb: 'Start with nothing and add sections yourself.' },
  { key: 'submittal', label: 'Submittal package',
    blurb: 'Cover, product data, shop drawings, approved materials, supporting documents.' },
  { key: 'closeout', label: 'Closeout package',
    blurb: 'Final drawings, warranty, care & maintenance, product data, materials, contacts, reports.' },
  { key: 'procurement', label: 'Procurement package',
    blurb: 'Purchase order, approved quote, proforma invoice, product documentation.' },
];

function officePdfToolItem(data) {
  return { id: uid('pkgi'), kind: 'file', name: '', sub: '', fileUrl: null, ...data };
}
function officePdfToolSection(title, items, note) {
  return { id: uid('pkgs'), title, note: note || '', divider: true, items: items || [] };
}

// Files already attached to a scope's document list, matched by document type.
function officePdfToolScopeDocs(scope, test) {
  return ((scope && scope.documents) || [])
    .filter(d => test(String(d.type || '') + ' ' + String(d.name || '')))
    .map(d => officePdfToolItem({ name: d.name, sub: d.type || '', fileUrl: d.fileUrl || null,
                                  ref: { kind: 'scopeDocument', documentId: d.id, scopeId: scope.id } }));
}
// A scope's chosen finishes, as words. Generated rather than merged because
// there is no file to merge — the selections live in the Hub, not in a PDF.
function officePdfToolSelectionLines(ctx, scope) {
  const lines = [];
  const family = (ctx.scopeLibrary || []).find(f => f.name === scope.familyName);
  const nameFor = (catId, optId) => {
    const cat = family && (family.categories || []).find(c => c.id === catId);
    const opt = cat && (cat.options || []).find(o => o.id === optId);
    return { cat: cat ? cat.name : catId, opt: opt ? opt.name : '' };
  };
  const emit = (areaName, map, finishes) => {
    if (areaName) lines.push({ label: '', value: areaName });
    Object.keys(map || {}).forEach(catId => {
      const optId = map[catId];
      const f = (finishes || {})[catId];
      const n = nameFor(catId, optId);
      const value = f && f.name ? `${f.name}${f.supplierName ? ' — ' + f.supplierName : ''}` : (n.opt || 'Not selected');
      lines.push({ label: n.cat, value });
    });
  };
  emit(scope.mainAreaName || '', scope.selections, scope.supplierFinishes);
  (scope.selectionAreas || []).forEach(a => emit(a.name || 'Area', a.selections, a.supplierFinishes));
  return lines;
}

// ── The three LEON workflows ──────────────────────────────────────────────
// Each reads the job's own records. Nothing below invents a section: if a
// project has no proforma invoices, the Procurement package simply has no PI
// section, and the person can see that it is missing rather than being handed
// an empty divider.
function officePdfToolSubmittalPackage(ctx, project, scope) {
  const sections = [];
  const shop = [];
  ((scope && scope.submittals) || []).forEach(t => {
    (t.revisions || []).slice().sort((a, b) => (a.revisionNumber || 0) - (b.revisionNumber || 0)).forEach(r => {
      shop.push(officePdfToolItem({
        name: r.file || `${t.name} — Rev. ${r.revisionNumber}`,
        sub: `${t.docType || 'Submittal'} · Rev. ${r.revisionNumber} · ${r.status || ''}`,
        fileUrl: r.fileUrl || null,
        // The link back to the real submittal record — this is what stops the
        // package becoming a disconnected copy of the drawings.
        ref: { kind: 'submittal', submittalId: t.id, revisionId: r.id, scopeId: scope.id, name: t.name },
      }));
    });
  });
  const productData = officePdfToolScopeDocs(scope, s => /product data|spec|cut ?sheet|data ?sheet/i.test(s));
  const filed = new Set(productData.map(i => i.ref && i.ref.documentId));
  const supporting = ((scope && scope.documents) || [])
    .filter(d => !filed.has(d.id) && !/shop drawing/i.test(String(d.type || '')))
    .map(d => officePdfToolItem({ name: d.name, sub: d.type || '', fileUrl: d.fileUrl || null,
                                  ref: { kind: 'scopeDocument', documentId: d.id, scopeId: scope.id } }));

  if (productData.length) sections.push(officePdfToolSection('Product Data', productData));
  if (shop.length) sections.push(officePdfToolSection('Shop Drawings', shop));
  sections.push(officePdfToolSection('Approved Materials', [officePdfToolItem({
    kind: 'text', name: 'Selected finishes and materials',
    lines: officePdfToolSelectionLines(ctx, scope) })]));
  if (supporting.length) sections.push(officePdfToolSection('Supporting Documents', supporting));
  return sections;
}

function officePdfToolCloseoutPackage(ctx, project) {
  const co = project.closeout || {};
  const docs = co.documents || [];
  const pick = re => docs.filter(d => re.test(String(d.type || '') + ' ' + String(d.name || '')))
    .map(d => officePdfToolItem({ name: d.name || d.type, sub: d.type || '', fileUrl: d.fileUrl || null,
                                  ref: { kind: 'closeoutDocument', documentId: d.id } }));
  const sections = [];
  const drawings = (project.drawingSets || []).filter(d => d.status !== 'Void')
    .map(d => officePdfToolItem({ name: d.name, sub: `Rev ${d.revision || '—'} · ${d.source || ''}`,
                                  fileUrl: d.fileUrl || null, ref: { kind: 'drawingSet', drawingSetId: d.id } }));
  const asBuilt = pick(/as[- ]built/i);
  if (drawings.length || asBuilt.length) sections.push(officePdfToolSection('Final Drawings', asBuilt.concat(drawings)));
  const warranty = pick(/warranty/i);
  sections.push(officePdfToolSection('Warranty', warranty.concat([officePdfToolItem({
    kind: 'text', name: 'Warranty terms',
    lines: [
      { label: 'Substantial completion', value: co.substantialCompletionDate ? fmtDate(co.substantialCompletionDate) : 'Not recorded' },
      { label: 'Warranty starts', value: co.warrantyStartDate ? fmtDate(co.warrantyStartDate) : 'Not recorded' },
      { label: 'Warranty period', value: (co.warrantyMonths || 0) + ' months' },
      { label: 'Warranty ends', value: warrantyEndDate(co) ? fmtDate(warrantyEndDate(co)) : 'Not recorded' },
      { label: 'Notes', value: co.warrantyNotes || '—' },
    ] })])));
  const care = pick(/care|maintenance|o ?& ?m|manual/i);
  if (care.length) sections.push(officePdfToolSection('Care & Maintenance', care));
  const pd = [];
  (project.scopes || []).forEach(s => officePdfToolScopeDocs(s, x => /product data|spec|manual|warranty/i.test(x))
    .forEach(i => pd.push({ ...i, sub: `${s.name} · ${i.sub}` })));
  if (pd.length) sections.push(officePdfToolSection('Product Data', pd));
  const matLines = [];
  (project.scopes || []).forEach(s => {
    matLines.push({ label: '', value: s.name });
    officePdfToolSelectionLines(ctx, s).forEach(l => matLines.push(l));
  });
  sections.push(officePdfToolSection('Approved Materials', [officePdfToolItem({
    kind: 'text', name: 'Materials as installed', lines: matLines })]));
  const contactLines = Object.keys(project.contacts || {}).map(role => {
    const c = project.contacts[role] || {};
    const bits = [c.company, c.person, c.phone, c.email].filter(Boolean).join(' · ');
    return { label: role, value: bits || '—' };
  });
  sections.push(officePdfToolSection('Contacts', [officePdfToolItem({
    kind: 'text', name: 'Project contacts', lines: contactLines })]));
  const reports = pick(/inspection|report|punch|sign[- ]off|lien|certificate/i);
  if (reports.length) sections.push(officePdfToolSection('Reports', reports));
  return sections;
}

function officePdfToolProcurementPackage(ctx, project, scopeId) {
  const inScope = r => !scopeId || r.scopeId === scopeId || !r.scopeId;
  const sections = [];
  const pos = (project.purchaseOrders || []).map(po => officePdfToolItem({
    name: po.poNumber, sub: `${po.vendorName || ''} · ${fmtMoney(po.amount || 0)}`,
    fileUrl: po.fileUrl || null, ref: { kind: 'purchaseOrder', purchaseOrderId: po.id } }));
  if (pos.length) {
    sections.push(officePdfToolSection('Purchase Order', pos.concat([officePdfToolItem({
      kind: 'text', name: 'Purchase order summary',
      lines: pos.map(p => ({ label: p.name, value: p.sub })) })])));
  }
  const quotes = [];
  (project.vendorEstimates || []).filter(inScope).forEach(ve => {
    (ve.revisions || []).forEach(r => {
      if (!r.fileUrl && !r.file) return;
      quotes.push(officePdfToolItem({
        name: r.file || `${ve.estimateNumber} Rev ${r.revision}`,
        sub: `${ve.vendorName || ''} · ${fmtMoney(r.amount || ve.amount || 0)}`,
        fileUrl: r.fileUrl || null,
        ref: { kind: 'vendorEstimate', vendorEstimateId: ve.id, revisionId: r.id } }));
    });
  });
  if (quotes.length) sections.push(officePdfToolSection('Approved Quote', quotes));
  const pis = (project.proformaInvoices || []).filter(inScope).map(pi => officePdfToolItem({
    name: pi.file || pi.piNumber, sub: `${pi.vendorName || ''} · ${fmtMoney(pi.amount || 0)}`,
    fileUrl: pi.fileUrl || null, ref: { kind: 'proformaInvoice', proformaInvoiceId: pi.id } }));
  if (pis.length) sections.push(officePdfToolSection('Proforma Invoice', pis));
  const pd = [];
  (project.scopes || []).filter(s => !scopeId || s.id === scopeId).forEach(s =>
    officePdfToolScopeDocs(s, x => /product data|spec|cut ?sheet|certificate|test/i.test(x))
      .forEach(i => pd.push({ ...i, sub: `${s.name} · ${i.sub}` })));
  if (pd.length) sections.push(officePdfToolSection('Product Documentation', pd));
  return sections;
}

// ── Resolving items to pages ──────────────────────────────────────────────
// Returns, per item, either a run of real pages or a reason it has none. The
// resolver is separate from the builder so the panel can show that answer
// BEFORE anyone presses build.
function officePdfToolResolveItems(sections, selfPlan, ctx) {
  const jobs = [];
  sections.forEach(s => (s.items || []).forEach(it => jobs.push({ section: s, item: it })));
  return jobs.reduce((chain, j) => chain.then(map => {
    const it = j.item;
    if (it.kind === 'text') { map[it.id] = { kind: 'text', pages: 1 }; return map; }
    if (it.kind === 'self') {
      const idx = it.indices && it.indices.length ? it.indices : selfPlan.map((p, i) => i);
      map[it.id] = { kind: 'self', pages: idx.length, plan: idx.map(i => selfPlan[i]).filter(Boolean) };
      return map;
    }
    if (it.kind === 'doc' && it.assetId) {
      return officePdfDocument(it.assetId).then(pdf => {
        map[it.id] = { kind: 'doc', pages: pdf.numPages, assetId: it.assetId };
        return map;
      }).catch(() => { map[it.id] = { kind: 'missing', pages: 0, why: 'That document is no longer in the store.' }; return map; });
    }
    if (!it.fileUrl) {
      map[it.id] = { kind: 'reference', pages: 0, why: 'Logged on the record by name — no file was uploaded.' };
      return map;
    }
    // Checking the package and then building it would otherwise decode and
    // store the same attachment twice, leaving an orphan copy in the asset
    // store on every press. The answer is cached on the item itself.
    if (it.resolvedAsset) {
      map[it.id] = { kind: 'file', pages: it.resolvedPages, assetId: it.resolvedAsset };
      return map;
    }
    return officePdfToolResolveAttachment(it).then(r => {
      if (r) { it.resolvedAsset = r.assetId; it.resolvedPages = r.pageCount; }
      map[it.id] = r
        ? { kind: 'file', pages: r.pageCount, assetId: r.assetId }
        : { kind: 'reference', pages: 0, why: 'The attachment is not a PDF or an image, so it cannot be merged.' };
      return map;
    });
  }), Promise.resolve({}));
}

function OfficePdfToolPackage({ ctx, doc, onChange, editable }) {
  const body = (doc && doc.body) || {};
  const selfPlan = useMemo(() => officePdfToolPlan(body), [body]);
  const project = doc.projectId ? (ctx.projects || []).find(p => p.id === doc.projectId) : null;
  const [scopeId, setScopeId] = useState(doc.scopeId || ((project && project.scopes && project.scopes[0]) ? project.scopes[0].id : ''));
  const [sections, setSections] = useState(() => (body.packageSections || []).length
    ? (body.packageSections || []).map(s => ({ ...s, items: s.items || [] }))
    : [officePdfToolSection('This document', [officePdfToolItem({ kind: 'self', name: doc.name })])]);
  const [opts, setOpts] = useState({ cover: true, toc: true, dividers: true, pageSize: 'Letter' });
  const [resolved, setResolved] = useState(null);
  const [result, setResult] = useState(null);
  const { job, run, retry, clear } = useOfficePdfToolJob();
  const scope = project ? (project.scopes || []).find(s => s.id === scopeId) : null;

  function loadWorkflow(kind) {
    if (kind === 'blank') { setSections([]); return; }
    if (!project) return;
    if (kind === 'submittal') {
      if (!scope) return;
      setSections(officePdfToolSubmittalPackage(ctx, project, scope));
    } else if (kind === 'closeout') {
      setSections(officePdfToolCloseoutPackage(ctx, project));
    } else if (kind === 'procurement') {
      setSections(officePdfToolProcurementPackage(ctx, project, scopeId || null));
    }
    setResolved(null); setResult(null);
  }

  function check() {
    run('package', 'Checking what can be merged', () =>
      officePdfToolResolveItems(sections, selfPlan, ctx).then(map => { setResolved(map); return map; }));
  }

  function moveSection(id, dir) {
    setSections(prev => {
      const i = prev.findIndex(s => s.id === id), j = i + dir;
      if (i < 0 || j < 0 || j >= prev.length) return prev;
      const next = prev.slice();
      next.splice(j, 0, next.splice(i, 1)[0]);
      return next;
    });
  }

  function build() {
    setResult(null);
    run('package', 'Building the package', onProgress =>
      officePdfToolResolveItems(sections, selfPlan, ctx).then(map => {
        setResolved(map);
        const tokens = officePdfToolTokens(ctx, doc, { scope: scope ? scope.name : '' });

        // LAY THE PACKAGE OUT ON PAPER FIRST. Every generated page is exactly
        // one page and every merged item's page count is already known, so the
        // finished page numbers can be computed before a single page is
        // written — which is the only way a contents page can be right. Doing
        // this in two passes (write, then splice) was tried and produced a
        // contents page one out whenever a section carried a generated page.
        const gen = [];                        // generated page specs, in order
        const genIndex = {};                   // key -> index in `gen`
        const addGen = (key, spec) => { genIndex[key] = gen.length; gen.push(spec); };

        let pageNo = 1;
        const coverAt = opts.cover ? pageNo++ : null;
        const tocAt = opts.toc ? pageNo++ : null;
        const secStart = {};
        sections.forEach(s => {
          secStart[s.id] = pageNo;
          if (opts.dividers) pageNo++;
          (s.items || []).forEach(it => { const r = map[it.id]; if (r) pageNo += r.pages; });
        });
        const totalPages = pageNo - 1;

        if (opts.cover) {
          addGen('cover', {
            eyebrow: tokens.company, title: doc.name,
            subtitle: [tokens.project, scope ? scope.name : ''].filter(Boolean).join(' - '),
            lines: [
              { label: 'Project no.', value: tokens.projectNumber || '-' },
              { label: 'Client', value: tokens.client || '-' },
              { label: 'Document no.', value: tokens.number || '-' },
              { label: 'Revision', value: tokens.revision },
              { label: 'Issued', value: tokens.date },
              { label: 'Prepared by', value: tokens.user || '-' },
            ],
            footer: tokens.address,
          });
        }
        if (opts.toc) {
          addGen('toc', {
            eyebrow: 'Contents', title: 'Table of Contents',
            lines: sections.map(s => ({ label: 'Page ' + secStart[s.id], value: s.title })),
            footer: `${sections.length} sections · ${totalPages} pages`,
          });
        }
        sections.forEach(s => {
          if (opts.dividers) {
            const merged = [], byRef = [];
            (s.items || []).forEach(it => {
              const r = map[it.id];
              if (r && r.pages) merged.push(`${it.name}${it.sub ? ' - ' + it.sub : ''}`);
              else byRef.push(`${it.name}${r && r.why ? ' - ' + r.why : ''}`);
            });
            const lines = merged.map(m => '· ' + m);
            if (byRef.length) {
              lines.push('');
              lines.push('INCLUDED BY REFERENCE (no file in the Hub to merge):');
              byRef.forEach(m => lines.push('· ' + m));
            }
            addGen('div:' + s.id, { eyebrow: tokens.project, title: s.title,
                                    subtitle: s.note || '', lines, footer: doc.name });
          }
          (s.items || []).forEach(it => {
            if (it.kind === 'text') {
              addGen('txt:' + it.id, { eyebrow: s.title, title: it.name || s.title,
                                       lines: it.lines || [], footer: doc.name });
            }
          });
        });

        const genPromise = gen.length
          ? officePdfToolMakeTextPages(gen, { pageSize: opts.pageSize })
              .then(d => officePdfSaveDoc(d))
              .then(bytes => officePdfToolSaveResult(bytes, 'package-pages.pdf',
                                                     { kind: 'pdf', from: 'package-generated' }))
          : Promise.resolve(null);

        return genPromise.then(genAsset => {
          const G = key => ({ assetId: genAsset.assetId, index: genIndex[key] });
          const plan = [];
          if (opts.cover) plan.push({ ...G('cover'), sourceRef: { kind: 'cover', name: 'Cover' } });
          if (opts.toc) plan.push({ ...G('toc'), sourceRef: { kind: 'toc', name: 'Contents' } });
          sections.forEach(s => {
            if (opts.dividers) plan.push({ ...G('div:' + s.id),
                                           sourceRef: { kind: 'divider', name: s.title, sectionId: s.id } });
            (s.items || []).forEach(it => {
              const r = map[it.id];
              if (!r || !r.pages) return;
              if (it.kind === 'text') {
                plan.push({ ...G('txt:' + it.id),
                            sourceRef: { kind: 'generated', name: it.name, sectionId: s.id } });
              } else if (r.kind === 'self') {
                r.plan.forEach(pp => plan.push({ assetId: pp.assetId, index: pp.index, rotate: pp.rotate,
                                                 crop: pp.crop,
                                                 sourceRef: { kind: 'self', name: it.name, sectionId: s.id } }));
              } else {
                for (let i = 0; i < r.pages; i++) {
                  plan.push({ assetId: r.assetId, index: i,
                              sourceRef: { ...(it.ref || {}), kind: (it.ref && it.ref.kind) || 'file',
                                           name: it.name, sourcePage: i + 1, sectionId: s.id } });
                }
              }
            });
          });
          if (!plan.length) throw new Error('Nothing in this package could be merged.');
          return officePdfAssemble(plan, { onProgress, title: doc.name,
                                           author: ctx.currentUserName || 'LEON Integra',
                                           subject: 'LEON package' })
            .then(out => officePdfSaveDoc(out))
            .then(bytes => officePdfToolSaveResult(bytes,
              `${officePdfToolSafeName(doc.name, 'package')} - package.pdf`, { kind: 'pdf', from: 'package' }))
            .then(r => {
              setResult({ ...r, plan });
              officePdfToolLog(ctx, doc,
                `Built a package of ${sections.length} section${sections.length === 1 ? '' : 's'} (${r.pageCount} pages).`);
              return r;
            });
        });
      })
    );
  }

  function adopt() {
    if (!result) return;
    officePdfToolPatch(ctx, doc, onChange, {
      pages: result.plan.map(p => makePdfPage(p.assetId, p.index, { rotate: p.rotate || 0, crop: p.crop || null,
                                                                   sourceRef: p.sourceRef || null })),
      pageCount: result.plan.length,
      workingAssetId: result.assetId,
      // The model's own binder field — four keys, exactly as declared.
      packageSections: sections.map(s => ({
        id: s.id, title: s.title, divider: !!s.divider,
        pageIds: result.plan.filter(p => p.sourceRef && p.sourceRef.sectionId === s.id).map((p, i) => i),
      })),
    });
    officePdfToolLog(ctx, doc, 'Adopted the built package as this document.');
  }

  const counts = resolved
    ? sections.map(s => (s.items || []).reduce((a, it) => {
        const r = resolved[it.id];
        return { pages: a.pages + ((r && r.pages) || 0), byRef: a.byRef + (r && !r.pages ? 1 : 0) };
      }, { pages: 0, byRef: 0 }))
    : null;

  return (
    <div className="space-y-3">
      <OfficePdfToolHeader tool="package" />

      <div className="rounded-lg border border-[var(--leon-line)] p-3 space-y-2">
        <p className="text-xs font-semibold uppercase tracking-wide text-[var(--leon-black)]/50">Start from</p>
        <div className="flex flex-wrap gap-1.5">
          {PDFTOOL_PACKAGE_KINDS.map(k => (
            <Button key={k.key} size="sm" variant="outline" disabled={!editable || (k.key !== 'blank' && !project)}
              title={k.blurb} onClick={() => loadWorkflow(k.key)}>{k.label}</Button>
          ))}
        </div>
        {!project && <p className="text-[11px] text-[var(--leon-black)]/45">The three LEON workflows read a job's own records — link this document to a project to use them.</p>}
        {project && !!(project.scopes || []).length && (
          <Field label="Scope">
            <Select value={scopeId} onChange={e => setScopeId(e.target.value)}>
              <option value="">Whole project</option>
              {(project.scopes || []).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
            </Select>
          </Field>
        )}
      </div>

      <div className="flex flex-wrap gap-3 text-xs">
        {['cover', 'toc', 'dividers'].map(k => (
          <label key={k} className="flex items-center gap-1.5">
            <input type="checkbox" checked={!!opts[k]} onChange={e => setOpts({ ...opts, [k]: e.target.checked })} />
            {k === 'cover' ? 'Cover page' : k === 'toc' ? 'Table of contents' : 'Divider page per section'}
          </label>
        ))}
        <label className="flex items-center gap-1.5">
          Generated page size
          <Select value={opts.pageSize} onChange={e => setOpts({ ...opts, pageSize: e.target.value })} className="!w-auto !py-1">
            {Object.keys(PDFTOOL_PAGE_SIZES).map(k => <option key={k} value={k}>{k}</option>)}
          </Select>
        </label>
      </div>

      {!sections.length && <EmptyState text="No sections yet. Pick a workflow above, or add one." />}
      <ul className="space-y-2">
        {sections.map((s, si) => (
          <li key={s.id} className="rounded-lg border border-[var(--leon-line)] bg-white">
            <div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--leon-line)]">
              <span className="text-xs font-mono text-[var(--leon-black)]/35 w-5">{si + 1}</span>
              <TextInput value={s.title} disabled={!editable}
                onChange={e => setSections(prev => prev.map(x => x.id === s.id ? { ...x, title: e.target.value } : x))}
                className="!py-1 !text-sm font-semibold" />
              {counts && (
                <Badge tone={counts[si].byRef ? 'yellow' : 'green'}>
                  {counts[si].pages} pg{counts[si].byRef ? ` · ${counts[si].byRef} by ref` : ''}
                </Badge>
              )}
              <IconAction icon="↑" title="Move up" disabled={!editable || si === 0} onClick={() => moveSection(s.id, -1)} />
              <IconAction icon="↓" title="Move down" disabled={!editable || si === sections.length - 1} onClick={() => moveSection(s.id, 1)} />
              <IconAction icon="✕" title="Remove section" disabled={!editable}
                onClick={() => setSections(prev => prev.filter(x => x.id !== s.id))} />
            </div>
            <ul className="px-3 py-2 space-y-1">
              {!(s.items || []).length && <li className="text-[11px] text-[var(--leon-black)]/40 italic">No items.</li>}
              {(s.items || []).map(it => {
                const r = resolved && resolved[it.id];
                return (
                  <li key={it.id} className="flex items-center gap-2 text-xs">
                    <span className="flex-1 min-w-0">
                      <span className="block truncate">{it.name || '(untitled)'}</span>
                      {it.sub && <span className="block text-[10px] text-[var(--leon-black)]/45 truncate">{it.sub}</span>}
                    </span>
                    {r && (r.pages
                      ? <span className="text-[10px] text-[#3a7d44] whitespace-nowrap">{r.pages} pg</span>
                      : <span className="text-[10px] text-[#a67b1f] whitespace-nowrap" title={r.why}>by reference</span>)}
                    <IconAction icon="✕" title="Remove" disabled={!editable}
                      onClick={() => setSections(prev => prev.map(x => x.id === s.id
                        ? { ...x, items: x.items.filter(y => y.id !== it.id) } : x))} />
                  </li>
                );
              })}
            </ul>
          </li>
        ))}
      </ul>

      <div className="flex flex-wrap items-center gap-2">
        <Button size="sm" variant="outline" disabled={!editable}
          onClick={() => setSections(prev => prev.concat([officePdfToolSection('New section',
            [officePdfToolItem({ kind: 'self', name: doc.name })])]))}>+ Section</Button>
        <Button size="sm" variant="outline" disabled={!sections.length} onClick={check}>Check what can be merged</Button>
        <Button size="sm" disabled={!editable || !sections.length} onClick={build}>Build the package</Button>
      </div>

      <OfficePdfToolJobBar job={job} onRetry={retry} onClear={clear} />
      <OfficePdfToolResult result={result} onAdopt={editable ? adopt : null} adoptLabel="Make this the document" />
      <OfficePdfToolNote>
        An item is merged only when its bytes are actually in the Hub — a PDF or image uploaded onto the record.
        Anything logged by name alone is printed on the section divider as <b>included by reference</b> rather than
        left out silently.
      </OfficePdfToolNote>
    </div>
  );
}

// ═══════════════════════════════════════════════════════ BRAND / FINISHING
// Everything here is drawn with pdf-lib TEXT onto the assembled document, not
// rasterized — a watermark, a footer and a Bates number stay selectable and
// searchable, which is the whole reason to draw them rather than flatten them.
//
// One constraint runs through it: pdf-lib's standard fonts are WinAnsi and
// throw on a character they cannot encode, so every string goes through
// officePdfToolAscii on the way to the page.

function officePdfToolInRange(i, spec, max) {
  if (!spec || /^all$/i.test(String(spec).trim())) return true;
  return officePdfToolParseRanges(spec, max).indexOf(i) >= 0;
}

function officePdfToolDrawFinishing(out, PDFLib, cfg) {
  const c = cfg || {};
  return Promise.all([
    out.embedFont(PDFLib.StandardFonts.Helvetica),
    out.embedFont(PDFLib.StandardFonts.HelveticaBold),
  ]).then(([plain, bold]) => {
    const pages = out.getPages();
    const total = pages.length;
    const ink = PDFLib.rgb(0.13, 0.12, 0.11);
    const faint = PDFLib.rgb(0.55, 0.53, 0.51);
    const stampRed = PDFLib.rgb(0.72, 0.15, 0.15);
    const wmGrey = PDFLib.rgb(0.45, 0.42, 0.40);
    const bx = c.bates || {};
    const batesTargets = (!bx.pages || /^all$/i.test(String(bx.pages).trim()))
      ? pages.map((p, k) => k)
      : officePdfToolParseRanges(bx.pages, total);

    pages.forEach((page, i) => {
      const { width, height } = page.getSize();
      const M = 30;

      // ── Watermark ───────────────────────────────────────────────────────
      const wm = c.watermark;
      if (wm && wm.text && officePdfToolInRange(i, wm.pages, total)) {
        const text = officePdfToolAscii(wm.text);
        const pos = wm.position || 'Diagonal';
        const angle = pos === 'Diagonal' ? (wm.rotation == null ? 45 : Number(wm.rotation)) : Number(wm.rotation || 0);
        // Sized to the paper, not to a guess: the mark reads the same on a
        // Letter sheet and on a D-size drawing.
        let size = Math.min(120, Math.max(18, (width * 0.72) / Math.max(4, text.length) * 1.6));
        let tw = bold.widthOfTextAtSize(text, size);
        while (tw > width * 0.86 && size > 10) { size -= 2; tw = bold.widthOfTextAtSize(text, size); }
        const rad = angle * Math.PI / 180;
        let cx = width / 2, cy = height / 2;
        if (pos === 'Top') cy = height - M - size;
        else if (pos === 'Bottom') cy = M + size;
        else if (pos === 'Left') cx = M + tw / 2;
        else if (pos === 'Right') cx = width - M - tw / 2;
        page.drawText(text, {
          x: cx - (tw / 2) * Math.cos(rad) + (size * 0.35) * Math.sin(rad),
          y: cy - (tw / 2) * Math.sin(rad) - (size * 0.35) * Math.cos(rad),
          size, font: bold, color: wmGrey,
          opacity: wm.opacity == null ? 0.18 : Number(wm.opacity),
          rotate: PDFLib.degrees(angle),
        });
      }

      // ── Header and footer ───────────────────────────────────────────────
      const hf = c.headerFooter;
      if (hf) {
        const shownPage = (hf.startAt == null ? 1 : Number(hf.startAt)) + i;
        const draw = (tpl, align, y) => {
          const s = officePdfToolAscii(officePdfToolFillTokens(tpl, c.tokens || {}, shownPage, total));
          if (!s) return;
          const w = plain.widthOfTextAtSize(s, 8);
          const x = align === 'center' ? (width - w) / 2 : align === 'right' ? width - M - w : M;
          page.drawText(s, { x, y, size: 8, font: plain, color: faint });
        };
        const head = hf.header || {};
        draw(head.left, 'left', height - M + 6);
        draw(head.center, 'center', height - M + 6);
        draw(head.right, 'right', height - M + 6);
        draw(hf.left, 'left', M - 14);
        draw(hf.center, 'center', M - 14);
        draw(hf.right, 'right', M - 14);
        if (hf.includePageNumbers && !/\{page\}/.test(String(hf.center || '') + String(hf.right || '') + String(hf.left || ''))) {
          const s = `${shownPage} of ${total}`;
          const w = plain.widthOfTextAtSize(s, 8);
          page.drawText(s, { x: (width - w) / 2, y: M - 26, size: 8, font: plain, color: faint });
        }
      }

      // ── Page label (A-101, SPEC-01…) ────────────────────────────────────
      const label = c.pageLabels && c.pageLabels[i];
      if (label) {
        const s = officePdfToolAscii(label);
        const w = bold.widthOfTextAtSize(s, 9);
        page.drawRectangle({ x: width - M - w - 10, y: height - M - 4, width: w + 10, height: 16,
                             color: PDFLib.rgb(1, 1, 1), opacity: 0.85 });
        page.drawText(s, { x: width - M - w - 5, y: height - M, size: 9, font: bold, color: ink });
      }

      // ── Bates / sequential document numbering ───────────────────────────
      // The number is the page's position in the NUMBERED SET, not its position
      // in the file: numbering pages 5-10 starts at the prefix's start number on
      // page 5, which is what a Bates range means to anyone who has ever had to
      // cite one.
      const b = c.bates;
      if (b && b.on && batesTargets.indexOf(i) >= 0) {
        const n = Number(b.start || 1) + batesTargets.indexOf(i);
        const s = officePdfToolAscii(String(b.prefix || 'LEON-') + String(n).padStart(Number(b.digits || 6), '0'));
        const w = bold.widthOfTextAtSize(s, 8);
        const x = /left/i.test(b.position || '') ? M : width - M - w;
        const y = /top/i.test(b.position || '') ? height - M + 6 : M - 24;
        page.drawText(s, { x, y, size: 8, font: bold, color: ink });
      }

      // ── Stamps, burned in ───────────────────────────────────────────────
      (c.stamps || []).filter(s => s.page === i || s.everyPage).forEach(s => {
        const text = officePdfToolAscii(s.text || '');
        if (!text) return;
        const size = Number(s.size || 16);
        const w = bold.widthOfTextAtSize(text, size);
        const boxW = w + 20, boxH = size + 14;
        const p = s.position || 'Top right';
        const x = /left/i.test(p) ? M : /center/i.test(p) ? (width - boxW) / 2 : width - M - boxW;
        const y = /top/i.test(p) ? height - M - boxH : /center/i.test(p) ? (height - boxH) / 2 : M;
        page.drawRectangle({ x, y, width: boxW, height: boxH, borderColor: stampRed, borderWidth: 1.6,
                             color: PDFLib.rgb(1, 1, 1), opacity: 0.9 });
        page.drawText(text, { x: x + 10, y: y + 9, size, font: bold, color: stampRed });
        if (s.sub) {
          const sw = plain.widthOfTextAtSize(officePdfToolAscii(s.sub), 7);
          page.drawText(officePdfToolAscii(s.sub), { x: x + boxW - 10 - sw, y: y - 10, size: 7, font: plain, color: faint });
        }
      });
    });
    return out;
  });
}

const PDFTOOL_HF_TOKEN_HELP = '{project} {projectNumber} {client} {document} {number} {revision} {status} {date} {company} {user} {page} {pages}';

function OfficePdfToolBrand({ ctx, doc, onChange, editable }) {
  const body = (doc && doc.body) || {};
  const plan = useMemo(() => officePdfToolPlan(body), [body]);
  const tokens = officePdfToolTokens(ctx, doc);
  const [hf, setHf] = useState(() => {
    const saved = body.headerFooter || {};
    return {
      left: '', center: '', right: '', includePageNumbers: true, startAt: 1,
      ...saved,
      // Additive nested block. makePdfBody declares left/center/right for ONE
      // band; a document that needs a title block at the top and a page number
      // at the foot needs two. A document with no header leaves these empty, so
      // nothing that already exists changes shape.
      header: { left: '', center: '', right: '', ...(saved.header || {}) },
    };
  });
  const [wm, setWm] = useState(() => body.watermark || { text: '', opacity: 0.18, rotation: 45, position: 'Diagonal', pages: 'all' });
  const [labels, setLabels] = useState({ template: 'A-{n}', start: 101, pages: 'all' });
  const [bates, setBates] = useState({ on: false, prefix: 'LEON-', start: 1, digits: 6, position: 'Bottom right', pages: 'all' });
  const [stampText, setStampText] = useState('');
  const [stampPos, setStampPos] = useState('Top right');
  const [stampPages, setStampPages] = useState('all');
  const [burnLeonStamps, setBurnLeonStamps] = useState(true);
  const [result, setResult] = useState(null);
  const { job, run, retry, clear } = useOfficePdfToolJob();

  // The page labels the document already carries, plus whatever the generator
  // above would produce — computed rather than stored so the preview and the
  // export cannot disagree.
  const computedLabels = useMemo(() => {
    const out = { ...(body.pageLabels || {}) };
    if (labels.template) {
      const idx = officePdfToolParseRanges(labels.pages === 'all' ? '' : labels.pages, plan.length);
      const targets = (labels.pages === 'all' || !idx.length) ? plan.map((p, i) => i) : idx;
      targets.forEach((pi, n) => {
        out[pi] = String(labels.template).replace(/\{n\}/g, String(Number(labels.start || 1) + n));
      });
    }
    return out;
  }, [body.pageLabels, labels, plan.length]);

  function stampList() {
    const list = [];
    if (burnLeonStamps) {
      (body.annotations || []).filter(a => a.type === 'Stamp').forEach(a => {
        list.push({ page: a.page, text: a.text || 'STAMP',
                    position: 'Top right', size: 16,
                    sub: `${a.author || ''}${a.date ? ' · ' + fmtDate(a.date) : ''}` });
      });
    }
    if (stampText) {
      const idx = officePdfToolParseRanges(stampPages === 'all' ? '' : stampPages, plan.length);
      const targets = (stampPages === 'all' || !idx.length) ? plan.map((p, i) => i) : idx;
      targets.forEach(pi => list.push({ page: pi, text: stampText, position: stampPos, size: 16,
                                        sub: `${ctx.currentUserName || ''} · ${fmtDate(todayISO())}` }));
    }
    return list;
  }

  function build() {
    setResult(null);
    run('brand', 'Drawing the finishing', onProgress =>
      officePdfLibs().then(({ PDFLib }) =>
        officePdfAssemble(plan, { onProgress, title: doc.name, author: ctx.currentUserName || 'LEON Integra' })
          .then(out => officePdfToolDrawFinishing(out, PDFLib, {
            tokens, headerFooter: hf, watermark: wm.text ? wm : null,
            pageLabels: computedLabels, bates, stamps: stampList(),
          }))
          .then(out => officePdfSaveDoc(out))
          .then(bytes => officePdfToolSaveResult(bytes,
            `${officePdfToolSafeName(doc.name, 'document')} — finished.pdf`, { kind: 'pdf', from: 'brand' }))
          .then(r => { setResult(r); return r; })
      ));
  }

  function save() {
    officePdfToolPatch(ctx, doc, onChange, { headerFooter: hf, watermark: wm.text ? wm : null, pageLabels: computedLabels });
    officePdfToolLog(ctx, doc, 'Updated page labels, header/footer and watermark.');
  }

  return (
    <div className="space-y-3">
      <OfficePdfToolHeader tool="brand" />

      <Collapsible title="Page labels" id={`pdftool-labels-${doc.id}`} defaultOpen>
        <div className="grid sm:grid-cols-3 gap-2">
          <Field label="Pattern" hint="{n} is the running number">
            <TextInput value={labels.template} disabled={!editable} onChange={e => setLabels({ ...labels, template: e.target.value })} />
          </Field>
          <Field label="Start at">
            <TextInput type="number" value={labels.start} disabled={!editable} onChange={e => setLabels({ ...labels, start: e.target.value })} />
          </Field>
          <Field label="Pages" hint="all, or 1-12">
            <TextInput value={labels.pages} disabled={!editable} onChange={e => setLabels({ ...labels, pages: e.target.value })} />
          </Field>
        </div>
        <p className="text-[11px] text-[var(--leon-black)]/50 mt-2">
          First few: {plan.slice(0, 6).map((p, i) => computedLabels[i] || '—').join(' · ')}
        </p>
      </Collapsible>

      <Collapsible title="Header and footer" id={`pdftool-hf-${doc.id}`}>
        <p className="text-[11px] text-[var(--leon-black)]/50 mb-2">Tokens: <span className="font-mono">{PDFTOOL_HF_TOKEN_HELP}</span></p>
        <div className="space-y-2">
          <p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--leon-black)]/45">Header</p>
          <div className="grid sm:grid-cols-3 gap-2">
            {['left', 'center', 'right'].map(k => (
              <Field key={k} label={k}>
                <TextInput value={(hf.header || {})[k] || ''} disabled={!editable}
                  onChange={e => setHf({ ...hf, header: { ...(hf.header || {}), [k]: e.target.value } })} />
              </Field>
            ))}
          </div>
          <p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--leon-black)]/45">Footer</p>
          <div className="grid sm:grid-cols-3 gap-2">
            {['left', 'center', 'right'].map(k => (
              <Field key={k} label={k}>
                <TextInput value={hf[k] || ''} disabled={!editable} onChange={e => setHf({ ...hf, [k]: e.target.value })} />
              </Field>
            ))}
          </div>
          <div className="flex flex-wrap items-center gap-3">
            <label className="flex items-center gap-1.5 text-xs">
              <input type="checkbox" checked={!!hf.includePageNumbers} disabled={!editable}
                onChange={e => setHf({ ...hf, includePageNumbers: e.target.checked })} />
              Page numbers
            </label>
            <Field label="First page is number">
              <TextInput type="number" className="!w-24" value={hf.startAt == null ? 1 : hf.startAt} disabled={!editable}
                onChange={e => setHf({ ...hf, startAt: e.target.value })} />
            </Field>
            <Button size="sm" variant="ghost" disabled={!editable}
              onClick={() => setHf({ ...hf, left: '{project}', center: '{document} — Rev {revision}', right: 'Page {page} of {pages}',
                                     header: { left: '{company}', center: '', right: '{number}' } })}>
              Use the standard LEON block
            </Button>
            <Button size="sm" variant="ghost" disabled={!editable}
              onClick={() => setHf({ ...hf, center: 'CONFIDENTIAL — {company}' })}>Add a confidentiality line</Button>
          </div>
        </div>
      </Collapsible>

      <Collapsible title="Watermark" id={`pdftool-wm-${doc.id}`}>
        <div className="grid sm:grid-cols-2 gap-2">
          <Field label="Text">
            <div className="flex gap-1.5">
              <Select value={PDF_WATERMARKS.indexOf(wm.text) >= 0 ? wm.text : ''} disabled={!editable}
                onChange={e => setWm({ ...wm, text: e.target.value })} className="!w-auto">
                <option value="">Custom…</option>
                {PDF_WATERMARKS.map(w => <option key={w} value={w}>{w}</option>)}
              </Select>
              <TextInput value={wm.text || ''} disabled={!editable} onChange={e => setWm({ ...wm, text: e.target.value })} />
            </div>
          </Field>
          <Field label="Position">
            <Select value={wm.position} disabled={!editable} onChange={e => setWm({ ...wm, position: e.target.value })}>
              {PDFTOOL_WM_POSITIONS.map(p => <option key={p} value={p}>{p}</option>)}
            </Select>
          </Field>
          <Field label={`Opacity — ${Math.round((wm.opacity == null ? 0.18 : wm.opacity) * 100)}%`}>
            <input type="range" min="5" max="80" value={Math.round((wm.opacity == null ? 0.18 : wm.opacity) * 100)}
              disabled={!editable} className="w-full"
              onChange={e => setWm({ ...wm, opacity: Number(e.target.value) / 100 })} />
          </Field>
          <Field label="Rotation (degrees)">
            <TextInput type="number" value={wm.rotation == null ? 45 : wm.rotation} disabled={!editable}
              onChange={e => setWm({ ...wm, rotation: e.target.value })} />
          </Field>
          <Field label="Pages" hint="all, or 1-3, 8">
            <TextInput value={wm.pages || 'all'} disabled={!editable} onChange={e => setWm({ ...wm, pages: e.target.value })} />
          </Field>
        </div>
      </Collapsible>

      <Collapsible title="Stamps" id={`pdftool-stamp-${doc.id}`}>
        <label className="flex items-center gap-1.5 text-xs mb-2">
          <input type="checkbox" checked={burnLeonStamps} onChange={e => setBurnLeonStamps(e.target.checked)} />
          Burn the stamps already placed on this document ({(body.annotations || []).filter(a => a.type === 'Stamp').length})
        </label>
        <div className="grid sm:grid-cols-3 gap-2">
          <Field label="Add a stamp">
            <Select value={stampText} disabled={!editable} onChange={e => setStampText(e.target.value)}>
              <option value="">None</option>
              {PDF_STAMP_LIBRARY.map(s => <option key={s} value={s}>{s}</option>)}
            </Select>
          </Field>
          <Field label="Position">
            <Select value={stampPos} disabled={!editable} onChange={e => setStampPos(e.target.value)}>
              {PDFTOOL_STAMP_POSITIONS.map(p => <option key={p} value={p}>{p}</option>)}
            </Select>
          </Field>
          <Field label="Pages">
            <TextInput value={stampPages} disabled={!editable} onChange={e => setStampPages(e.target.value)} />
          </Field>
        </div>
        <OfficePdfToolNote>
          A stamp is drawn onto the EXPORTED file. It is not written back onto the working document, so the
          markup layer stays editable and the same document can be stamped APPROVED for one recipient and
          FOR RECORD for another.
        </OfficePdfToolNote>
      </Collapsible>

      <Collapsible title="Bates / sequential numbering" id={`pdftool-bates-${doc.id}`}>
        <label className="flex items-center gap-1.5 text-xs mb-2">
          <input type="checkbox" checked={bates.on} disabled={!editable} onChange={e => setBates({ ...bates, on: e.target.checked })} />
          Number every page in sequence
        </label>
        <div className="grid sm:grid-cols-4 gap-2">
          <Field label="Prefix"><TextInput value={bates.prefix} disabled={!editable || !bates.on} onChange={e => setBates({ ...bates, prefix: e.target.value })} /></Field>
          <Field label="Start at"><TextInput type="number" value={bates.start} disabled={!editable || !bates.on} onChange={e => setBates({ ...bates, start: e.target.value })} /></Field>
          <Field label="Digits"><TextInput type="number" value={bates.digits} disabled={!editable || !bates.on} onChange={e => setBates({ ...bates, digits: e.target.value })} /></Field>
          <Field label="Position">
            <Select value={bates.position} disabled={!editable || !bates.on} onChange={e => setBates({ ...bates, position: e.target.value })}>
              {['Bottom right', 'Bottom left', 'Top right', 'Top left'].map(p => <option key={p} value={p}>{p}</option>)}
            </Select>
          </Field>
        </div>
        {bates.on && (
          <p className="text-[11px] text-[var(--leon-black)]/55 mt-2">
            {plan.length ? `${bates.prefix}${String(Number(bates.start || 1)).padStart(Number(bates.digits || 6), '0')} → ${bates.prefix}${String(Number(bates.start || 1) + plan.length - 1).padStart(Number(bates.digits || 6), '0')}` : '—'}
            {' '}· recorded in this document's activity when the file is written, so the range can be traced later.
          </p>
        )}
      </Collapsible>

      <OfficePdfToolJobBar job={job} onRetry={retry} onClear={clear} />
      <div className="flex items-center gap-2 flex-wrap">
        <Button size="sm" disabled={!editable || !plan.length} onClick={build}>Write the finished PDF</Button>
        <Button size="sm" variant="outline" disabled={!editable} onClick={save}>Save these settings on the document</Button>
      </div>
      <OfficePdfToolResult result={result}
        onAdopt={editable && result ? () => {
          officePdfToolPatch(ctx, doc, onChange, { workingAssetId: result.assetId });
          officePdfToolLog(ctx, doc, `Finishing applied — ${bates.on ? 'Bates numbered, ' : ''}${wm.text ? 'watermarked "' + wm.text + '", ' : ''}${plan.length} pages.`);
        } : null}
        adoptLabel="File this as the current build" />
      <OfficePdfToolNote>
        Everything above is drawn as real text on a NEW file. The working document keeps its own pages, so a
        watermark can be changed and re-issued without ever having damaged the source.
      </OfficePdfToolNote>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════ REDACTION
//
// THE ONE THING THAT MATTERS HERE. A black rectangle drawn over text is not a
// redaction. The text objects are still in the file; select-all and paste, or
// any command-line tool, gets them back. Every "redaction" that works that way
// is a leak waiting for someone to open the file in something other than a
// viewer.
//
// So applying a redaction RASTERIZES the affected pages: the page is re-rendered
// as an image with the marked areas painted out, and the image is what goes in
// the file. There are no text objects left to recover because there is no text
// on that page any more. That trade-off is real and is stated on the screen —
// a redacted page stops being selectable and searchable — and it is why only
// the pages that carry a mark are rasterized. Everything else stays real text.

// A rect read off the text layer is in the UNROTATED page's coordinates; a page
// shown rotated needs it turned to match, or the box lands on the wrong edge.
// Normalising at MARK time means the painter never has to ask which space a
// rect is in — there is only one.
function officePdfToolRotateRect(rect, deg) {
  const d = ((Number(deg) || 0) % 360 + 360) % 360;
  if (d === 90) return { x: 1 - rect.y - rect.h, y: rect.x, w: rect.h, h: rect.w };
  if (d === 180) return { x: 1 - rect.x - rect.w, y: 1 - rect.y - rect.h, w: rect.w, h: rect.h };
  if (d === 270) return { x: rect.y, y: 1 - rect.x - rect.w, w: rect.h, h: rect.w };
  return { ...rect };
}

// officePdfRasterize renders the WHOLE page — it has no idea about the LEON
// crop box, which lives on the page plan and is only honoured by the assembler.
// On a redaction that is not a cosmetic difference: a cropped page rasterized
// whole would hand back the content the crop was hiding. So every rasterize in
// this file masks the out-of-crop area first. It is painted white rather than
// black because that is what the crop looked like — and either way the pixels
// are genuinely not in the output.
function officePdfToolMaskCrop(cx, r, p) {
  const cr = p && p.crop;
  if (!cr || !cr.w || !cr.h) return;
  cx.save();
  cx.fillStyle = '#ffffff';
  cx.fillRect(0, 0, r.width, cr.y * r.height);
  cx.fillRect(0, (cr.y + cr.h) * r.height, r.width, r.height - (cr.y + cr.h) * r.height);
  cx.fillRect(0, 0, cr.x * r.width, r.height);
  cx.fillRect((cr.x + cr.w) * r.width, 0, r.width - (cr.x + cr.w) * r.width, r.height);
  cx.restore();
}

function officePdfToolApplyRedactions(plan, reds, docName, onProgress) {
  const byPage = {};
  reds.forEach(r => { (byPage[r.page] = byPage[r.page] || []).push(r); });
  const affected = Object.keys(byPage).map(Number).filter(i => plan[i]).sort((a, b) => a - b);
  if (!affected.length) return Promise.reject(new Error('Nothing is marked for redaction.'));
  const rasterPlan = affected.map(i => plan[i]);
  return officePdfRasterize(rasterPlan, {
    dpi: 200, quality: 0.92, onProgress,
    paint: (cx, r, p, n) => {
      officePdfToolMaskCrop(cx, r, p);
      cx.save();
      cx.fillStyle = '#000000';
      (byPage[affected[n]] || []).forEach(red => {
        const rc = red.rect || {};
        cx.fillRect(rc.x * r.width, rc.y * r.height, Math.max(1, rc.w * r.width), Math.max(1, rc.h * r.height));
      });
      cx.restore();
    },
  })
    .then(d => officePdfSaveDoc(d))
    .then(bytes => officePdfToolSaveResult(bytes, 'redacted-pages.pdf', { kind: 'pdf', from: 'redact-raster' }))
    .then(tmp => {
      // Only the rasterized pages are swapped out. A rasterized page has its
      // rotation baked in, so its plan entry carries none.
      const finalPlan = plan.map((p, i) => {
        const at = affected.indexOf(i);
        return at >= 0 ? { assetId: tmp.assetId, index: at, rotate: 0, crop: null } : p;
      });
      return officePdfAssemble(finalPlan, { title: docName + ' (redacted)' })
        .then(out => officePdfSaveDoc(out))
        .then(b2 => officePdfToolSaveResult(b2,
          `${officePdfToolSafeName(docName, 'document')} — redacted.pdf`, { kind: 'pdf', from: 'redact' }))
        .then(r => ({ ...r, plan: finalPlan, rasterized: affected }));
    });
}

// SANITIZE. Rebuilding through officePdfAssemble already drops the document
// catalog — outline, AcroForm, embedded files, JavaScript, OpenAction — because
// copyPages copies PAGES, not the catalog. What it does NOT drop is a page's own
// /Annots, which travel with the page dictionary; those are removed explicitly
// below. Anything drawn INTO the page content is content, not metadata, and
// nothing here can remove it — that is what redaction is for, and the panel
// says so rather than letting "sanitize" imply more than it does.
function officePdfToolSanitize(plan, docName, meta, onProgress) {
  return officePdfLibs().then(({ PDFLib }) =>
    officePdfAssemble(plan, { onProgress }).then(out => {
      out.getPages().forEach(page => {
        try { page.node.delete(PDFLib.PDFName.of('Annots')); } catch (e) { /* page had none */ }
      });
      ['AcroForm', 'Names', 'Outlines', 'OpenAction', 'Metadata'].forEach(k => {
        try { out.catalog.delete(PDFLib.PDFName.of(k)); } catch (e) { /* not present */ }
      });
      out.setTitle(officePdfToolAscii((meta && meta.title) || docName));
      out.setAuthor(officePdfToolAscii((meta && meta.author) || ''));
      out.setSubject(officePdfToolAscii((meta && meta.subject) || ''));
      out.setKeywords([]);
      out.setProducer('LEON PDF');
      out.setCreator('LEON Operations Hub');
      return out;
    }).then(out => officePdfSaveDoc(out))
      .then(bytes => officePdfToolSaveResult(bytes,
        `${officePdfToolSafeName(docName, 'document')} — sanitized.pdf`, { kind: 'pdf', from: 'sanitize' }))
  );
}

function OfficePdfToolRedact({ ctx, doc, onChange, editable }) {
  const body = (doc && doc.body) || {};
  const plan = useMemo(() => officePdfToolPlan(body), [body]);
  const reds = (body.redactions || []).filter(r => !r.applied);
  const applied = (body.redactions || []).filter(r => r.applied);
  const [term, setTerm] = useState('');
  const [caseSensitive, setCaseSensitive] = useState(false);
  const [wholeWord, setWholeWord] = useState(false);
  const [reason, setReason] = useState('');
  const [manual, setManual] = useState({ page: 1, x: 10, y: 10, w: 40, h: 5 });
  const [result, setResult] = useState(null);
  const [confirmDelete, setConfirmDelete] = useState(false);
  const [sanResult, setSanResult] = useState(null);
  const { job, run, retry, clear } = useOfficePdfToolJob();

  function markAll() {
    if (!term.trim()) return;
    run('redact-find', 'Searching every page', onProgress =>
      // Read the text fresh rather than trusting a stored index: a marked
      // redaction has to correspond to what is in the file NOW.
      plan.reduce((chain, p, i) => chain.then(acc =>
        Promise.all([officePdfPageText(p.assetId, p.index), officePdfPageSize(p.assetId, p.index)])
          .then(([pt, sz]) => {
            const rot = ((sz.rotation || 0) + (p.rotate || 0)) % 360;
            officePdfSearch([{ ...pt, page: i }], term, { caseSensitive, wholeWord }).forEach(m => {
              // One redaction per match, boxed over every text run it touches,
              // padded slightly so descenders and antialiasing are covered.
              const rects = m.rects || [];
              if (!rects.length) return;
              const x0 = Math.min(...rects.map(r => r.x)), y0 = Math.min(...rects.map(r => r.y));
              const x1 = Math.max(...rects.map(r => r.x + r.w)), y1 = Math.max(...rects.map(r => r.y + r.h));
              const raw = { x: Math.max(0, x0 - 0.002), y: Math.max(0, y0 - 0.004),
                            w: Math.min(1, x1 - x0 + 0.004), h: Math.min(1, y1 - y0 + 0.008) };
              acc.push(makePdfRedaction({ page: i, rect: officePdfToolRotateRect(raw, rot),
                                          matchedText: m.match, reason: reason || `Search: "${term}"` },
                                        ctx.currentUserName || ''));
            });
            onProgress(i + 1, plan.length);
            return acc;
          })
      ), Promise.resolve([])).then(found => {
        if (!found.length) return null;
        officePdfToolPatch(ctx, doc, onChange, { redactions: (body.redactions || []).concat(found) });
        officePdfToolLog(ctx, doc, `Marked ${found.length} occurrence${found.length === 1 ? '' : 's'} of "${term}" for redaction.`);
        return found;
      })
    );
  }

  function addManual() {
    const p = officePdfToolClamp(parseInt(manual.page, 10) - 1, 0, Math.max(0, plan.length - 1));
    const r = makePdfRedaction({
      page: p,
      rect: { x: Number(manual.x) / 100, y: Number(manual.y) / 100, w: Number(manual.w) / 100, h: Number(manual.h) / 100 },
      reason: reason || 'Marked by hand',
    }, ctx.currentUserName || '');
    officePdfToolPatch(ctx, doc, onChange, { redactions: (body.redactions || []).concat([r]) });
  }

  function remove(id) {
    officePdfToolPatch(ctx, doc, onChange, { redactions: (body.redactions || []).filter(r => r.id !== id) });
  }

  function apply() {
    setResult(null);
    run('redact', 'Rasterizing the marked pages', onProgress =>
      officePdfToolApplyRedactions(plan, reds, doc.name, onProgress).then(r => {
        setResult(r);
        officePdfToolPatch(ctx, doc, onChange, {
          redactions: (body.redactions || []).map(x => reds.some(y => y.id === x.id)
            ? { ...x, applied: true, appliedDate: todayISO() } : x),
        });
        officePdfToolLog(ctx, doc,
          `Applied ${reds.length} redaction${reds.length === 1 ? '' : 's'} — ${r.rasterized.length} page${r.rasterized.length === 1 ? '' : 's'} rasterized, content removed from the output.`);
        return r;
      })
    );
  }

  function adopt() {
    if (!result) return;
    officePdfToolPatch(ctx, doc, onChange, {
      pages: result.plan.map(p => makePdfPage(p.assetId, p.index, { rotate: p.rotate || 0, crop: p.crop || null })),
      pageCount: result.plan.length,
      workingAssetId: result.assetId,
      flattened: true,
    });
    officePdfToolLog(ctx, doc, 'Redacted build adopted as this document.');
  }

  function purgeOriginal() {
    const id = body.originalAssetId;
    if (!id) return;
    officePdfDeleteAsset(id).then(() => {
      officePdfForget(id);
      officePdfToolPatch(ctx, doc, onChange, { originalAssetId: result ? result.assetId : body.workingAssetId });
      officePdfToolLog(ctx, doc, 'Deleted the un-redacted original file from the document store. This cannot be undone.');
      setConfirmDelete(false);
    });
  }

  function sanitize() {
    setSanResult(null);
    run('sanitize', 'Rebuilding without annotations or metadata', onProgress =>
      officePdfToolSanitize(plan, doc.name,
        { title: doc.name, author: ctx.currentUserName || '', subject: (body.category || '') }, onProgress)
        .then(r => { setSanResult(r); officePdfToolLog(ctx, doc, 'Wrote a sanitized copy.'); return r; })
    );
  }

  return (
    <div className="space-y-3">
      <OfficePdfToolHeader tool="redact" />
      <OfficePdfToolNote tone="warn">
        <b>Marking is not redacting.</b> A mark is a proposal you can review and remove. Applying re-renders every
        marked page as an image with the areas painted out, so the words are not in the file at all — and those
        pages stop being selectable or searchable. Pages with no mark on them are left as real text.
        Anything a page crop was hiding is removed from a redacted page too, since a crop only hides content
        and this has to remove it.
      </OfficePdfToolNote>

      <Collapsible title="Find and mark every occurrence" id={`pdftool-redfind-${doc.id}`} defaultOpen>
        <div className="flex flex-wrap items-end gap-2">
          <Field label="Search term" className="flex-1 min-w-[180px]">
            <TextInput value={term} disabled={!editable} onChange={e => setTerm(e.target.value)}
              placeholder="A name, a price, an address…" />
          </Field>
          <Field label="Reason (recorded on each mark)" className="flex-1 min-w-[180px]">
            <TextInput value={reason} disabled={!editable} onChange={e => setReason(e.target.value)} />
          </Field>
          <Button size="sm" disabled={!editable || !term.trim() || !plan.length} onClick={markAll}>Find and mark</Button>
        </div>
        <div className="flex gap-4 mt-2 text-xs">
          <label className="flex items-center gap-1.5"><input type="checkbox" checked={caseSensitive} onChange={e => setCaseSensitive(e.target.checked)} />Match case</label>
          <label className="flex items-center gap-1.5"><input type="checkbox" checked={wholeWord} onChange={e => setWholeWord(e.target.checked)} />Whole word</label>
        </div>
        <p className="text-[11px] text-[var(--leon-black)]/45 mt-2">
          This reads the text layer. A scanned page has no text layer, so nothing on it can be found this way —
          mark those areas by hand.
        </p>
      </Collapsible>

      <Collapsible title="Mark an area by hand" id={`pdftool-redarea-${doc.id}`}>
        <div className="grid grid-cols-5 gap-2">
          {[['page', 'Page'], ['x', 'X %'], ['y', 'Y %'], ['w', 'W %'], ['h', 'H %']].map(([k, l]) => (
            <Field key={k} label={l}>
              <TextInput type="number" value={manual[k]} disabled={!editable}
                onChange={e => setManual({ ...manual, [k]: e.target.value })} />
            </Field>
          ))}
        </div>
        <Button size="sm" variant="outline" className="mt-2" disabled={!editable || !plan.length} onClick={addManual}>Add this area</Button>
        <p className="text-[11px] text-[var(--leon-black)]/45 mt-1">
          Percentages of the page, measured from the top-left. Dragging a box on the page itself is done in the
          viewer; this is here for a precise figure or a page the viewer cannot show.
        </p>
      </Collapsible>

      <div>
        <div className="flex items-center gap-2 mb-1.5">
          <h4 className="text-sm font-bold">Marked, not yet applied</h4>
          <Badge tone={reds.length ? 'yellow' : 'neutral'}>{reds.length}</Badge>
        </div>
        {!reds.length && <EmptyState text="Nothing is marked." />}
        <ul className="space-y-1 max-h-56 overflow-y-auto">
          {reds.map(r => (
            <li key={r.id} className="flex items-center gap-2 text-xs rounded border border-[var(--leon-line)] px-2 py-1.5 bg-white">
              <span className="font-mono text-[var(--leon-black)]/45 w-12">p.{(r.page || 0) + 1}</span>
              <span className="flex-1 min-w-0 truncate">{r.matchedText ? `"${r.matchedText}"` : 'Area'}<span className="text-[var(--leon-black)]/45"> — {r.reason || 'No reason given'}</span></span>
              <IconAction icon="✕" title="Remove this mark" disabled={!editable} onClick={() => remove(r.id)} />
            </li>
          ))}
        </ul>
        {!!applied.length && (
          <p className="text-[11px] text-[var(--leon-black)]/45 mt-1.5">
            {applied.length} redaction{applied.length === 1 ? '' : 's'} already applied and kept on the record.
          </p>
        )}
      </div>

      <OfficePdfToolJobBar job={job} onRetry={retry} onClear={clear} />
      <Button size="sm" variant="danger" disabled={!editable || !reds.length} onClick={apply}>
        Apply {reds.length || ''} redaction{reds.length === 1 ? '' : 's'}
      </Button>

      <OfficePdfToolResult result={result} onAdopt={editable ? adopt : null} adoptLabel="Make this the document">
        {result && (
          <p className="text-[11px] text-[var(--leon-black)]/60">
            {result.rasterized.length} page{result.rasterized.length === 1 ? '' : 's'} rasterized
            ({officePdfToolRangeLabel(result.rasterized)}); the rest are unchanged text.
          </p>
        )}
      </OfficePdfToolResult>

      {result && body.originalAssetId && (
        <div className="rounded-lg border border-[#e8b8b8] bg-[#fdf1f1] px-3 py-2.5 space-y-2">
          <p className="text-[11.5px] text-[#8d3232] leading-relaxed">
            <b>The un-redacted original is still in the document store.</b> That is deliberate — until you have
            checked the output, throwing it away would be the mistake. If the redacted content must not exist
            anywhere in the Hub, delete it now. There is no undo.
          </p>
          {confirmDelete
            ? <ConfirmBar text="Permanently delete the original file?" confirmLabel="Delete it" tone="danger"
                onConfirm={purgeOriginal} onCancel={() => setConfirmDelete(false)} />
            : <Button size="sm" variant="danger" disabled={!editable} onClick={() => setConfirmDelete(true)}>Delete the original file</Button>}
        </div>
      )}

      <Collapsible title="Sanitize — strip annotations, form data and metadata" id={`pdftool-san-${doc.id}`}>
        <Button size="sm" variant="outline" disabled={!editable || !plan.length} onClick={sanitize}>Write a sanitized copy</Button>
        <div className="mt-2 grid sm:grid-cols-2 gap-2 text-[11.5px]">
          <div className="rounded-md border border-[#bcd9c2] bg-[#f3faf4] px-3 py-2">
            <p className="font-semibold text-[#3a7d44] mb-1">Removed</p>
            <ul className="space-y-0.5 text-[var(--leon-black)]/70">
              <li>· Every page annotation — comments, highlights, sticky notes, stamps, links</li>
              <li>· Form fields and everything typed into them</li>
              <li>· The document outline (bookmarks)</li>
              <li>· Embedded files and embedded JavaScript</li>
              <li>· Title, author, subject, keywords, producer and the XMP block — replaced with LEON's own</li>
            </ul>
          </div>
          <div className="rounded-md border border-[#e0c07a] bg-[#fdf7ea] px-3 py-2">
            <p className="font-semibold text-[#7a5c12] mb-1">Not removed</p>
            <ul className="space-y-0.5 text-[var(--leon-black)]/70">
              <li>· Anything drawn into the page itself — that is content, not metadata</li>
              <li>· Text hidden behind a graphic, which is still text</li>
              <li>· Optional-content layers that the page's own drawing refers to</li>
            </ul>
            <p className="mt-1 text-[var(--leon-black)]/60">For any of those, redact the area. Sanitizing will not do it.</p>
          </div>
        </div>
        <OfficePdfToolResult result={sanResult} />
      </Collapsible>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════════ PROTECT
function OfficePdfToolProtect({ ctx, doc, onChange, editable }) {
  const body = (doc && doc.body) || {};
  const sec = body.security || { restrictEditing: false, restrictPrinting: false, passwordSet: false };
  function set(k, v) {
    officePdfToolPatch(ctx, doc, onChange, { security: { ...sec, [k]: v } });
    officePdfToolLog(ctx, doc, `Document policy — ${k} set to ${v ? 'on' : 'off'}.`);
  }
  return (
    <div className="space-y-3">
      <OfficePdfToolHeader tool="protect" />
      <OfficePdfToolNote tone="warn">
        <b>These are not encrypted PDF restrictions.</b> The writer this app uses (pdf-lib) cannot encrypt a PDF,
        and a browser has nowhere safe to keep a key. What is set below is <b>LEON document policy</b>, enforced by
        the Hub's own permissions on this screen — anyone who has the exported file has an ordinary, unprotected
        PDF. If a file genuinely has to travel locked, encrypt it outside the Hub before it goes.
      </OfficePdfToolNote>

      <div className="space-y-2">
        <label className="flex items-start gap-2 rounded-lg border border-[var(--leon-line)] bg-white px-3 py-2.5">
          <input type="checkbox" className="mt-0.5" checked={!!sec.restrictEditing} disabled={!editable}
            onChange={e => set('restrictEditing', e.target.checked)} />
          <span>
            <span className="block text-sm font-semibold">Restrict editing</span>
            <span className="block text-[11px] text-[var(--leon-black)]/55">
              In the Hub, only someone with edit rights on LEON Softwares can change this document's pages,
              markup or fields. It has no effect on the exported file.
            </span>
          </span>
        </label>
        <label className="flex items-start gap-2 rounded-lg border border-[var(--leon-line)] bg-white px-3 py-2.5">
          <input type="checkbox" className="mt-0.5" checked={!!sec.restrictPrinting} disabled={!editable}
            onChange={e => set('restrictPrinting', e.target.checked)} />
          <span>
            <span className="block text-sm font-semibold">Restrict printing and export</span>
            <span className="block text-[11px] text-[var(--leon-black)]/55">
              Hides the print and export actions in the Hub for anyone without edit rights. Anyone who already
              has a copy of the file can still print it.
            </span>
          </span>
        </label>
        <div className="flex items-start gap-2 rounded-lg border border-dashed border-[var(--leon-line)] px-3 py-2.5 opacity-70">
          <input type="checkbox" className="mt-0.5" checked={false} disabled readOnly />
          <span>
            <span className="block text-sm font-semibold">Password on the file itself</span>
            <span className="block text-[11px] text-[var(--leon-black)]/55">
              Not available. This would need a PDF encryptor, which pdf-lib is not, and a place to hold the key
              that a page anyone can view-source is not. The field exists in the model so a future service can
              fill it — nothing here will pretend to have set one.
            </span>
          </span>
        </div>
      </div>

      {(sec.restrictEditing || sec.restrictPrinting) && (
        <p className="text-[11px] text-[var(--leon-black)]/50">
          Policy set by {ctx.currentUserName || 'someone'} · recorded in this document's activity.
        </p>
      )}
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════════ COMPARE
//
// Two comparisons, because they answer two different questions and neither one
// answers the other's. The TEXT diff says what the words now say — which is the
// question on a specification or a contract. The PIXEL diff says what the page
// now looks like — which is the question on a drawing, where a dimension can
// move without a single character changing.
//
// Both are computed here, from the file, every time. Nothing is stored, so a
// comparison can never be stale.
//
// Architectural overlay — aligning two drawings by their title block, matching
// grids, calling out what moved — is Drawing Intelligence's job, not this one.
// What is below is a straight same-size pixel difference.

// Longest common subsequence, iterative, over an array of comparable strings.
// A real LCS rather than a heuristic, because "these two lines are the same
// line, moved" is exactly the case a naive line-by-line walk gets wrong.
function officePdfToolLcsOps(a, b) {
  const m = a.length, n = b.length;
  if (!m && !n) return [];
  if (!m) return b.map(t => ({ op: 'add', text: t }));
  if (!n) return a.map(t => ({ op: 'del', text: t }));
  // The DP table is (m+1)(n+1) Int32s. Past a few million cells this stops
  // being a diff and starts being a hung tab, so it is refused out loud rather
  // than attempted.
  if ((m + 1) * (n + 1) > 6000000) return null;
  const W = n + 1;
  const dp = new Int32Array((m + 1) * W);
  for (let i = m - 1; i >= 0; i--) {
    for (let j = n - 1; j >= 0; j--) {
      dp[i * W + j] = a[i] === b[j]
        ? dp[(i + 1) * W + j + 1] + 1
        : Math.max(dp[(i + 1) * W + j], dp[i * W + j + 1]);
    }
  }
  const ops = [];
  let i = 0, j = 0;
  while (i < m && j < n) {
    if (a[i] === b[j]) { ops.push({ op: 'same', text: a[i] }); i++; j++; }
    else if (dp[(i + 1) * W + j] >= dp[i * W + j + 1]) { ops.push({ op: 'del', text: a[i] }); i++; }
    else { ops.push({ op: 'add', text: b[j] }); j++; }
  }
  while (i < m) ops.push({ op: 'del', text: a[i++] });
  while (j < n) ops.push({ op: 'add', text: b[j++] });
  return ops;
}

function officePdfToolLines(text) {
  return String(text || '').split('\n').map(s => s.replace(/\s+/g, ' ').trim()).filter(Boolean);
}
function officePdfToolWords(text) {
  return String(text || '').split(/\s+/).filter(Boolean);
}

// Line-level LCS first, then a word-level LCS inside each paired changed line.
// Diffing words across a whole page produces a ribbon nobody can read; diffing
// lines alone cannot show that one number changed. Doing both is what makes the
// result look like a redline.
function officePdfToolDiffText(textA, textB) {
  const la = officePdfToolLines(textA), lb = officePdfToolLines(textB);
  const ops = officePdfToolLcsOps(la, lb);
  if (!ops) return { tooBig: true, rows: [], stats: { same: 0, added: 0, removed: 0, changed: 0 } };
  const rows = [];
  let i = 0;
  while (i < ops.length) {
    if (ops[i].op === 'same') { rows.push({ kind: 'same', text: ops[i].text }); i++; continue; }
    const dels = [], adds = [];
    while (i < ops.length && ops[i].op !== 'same') { (ops[i].op === 'del' ? dels : adds).push(ops[i].text); i++; }
    const pairs = Math.min(dels.length, adds.length);
    for (let k = 0; k < pairs; k++) {
      rows.push({ kind: 'changed', a: dels[k], b: adds[k],
                  inline: officePdfToolLcsOps(officePdfToolWords(dels[k]), officePdfToolWords(adds[k])) || [] });
    }
    for (let k = pairs; k < dels.length; k++) rows.push({ kind: 'removed', text: dels[k] });
    for (let k = pairs; k < adds.length; k++) rows.push({ kind: 'added', text: adds[k] });
  }
  const stats = rows.reduce((s, r) => { s[r.kind === 'same' ? 'same' : r.kind]++; return s; },
                            { same: 0, added: 0, removed: 0, changed: 0 });
  return { tooBig: false, rows, stats };
}

// ── Pixel comparison ──────────────────────────────────────────────────────
function officePdfToolPadCanvas(src, w, h) {
  if (src.width === w && src.height === h) return src;
  const c = document.createElement('canvas');
  c.width = w; c.height = h;
  const cx = c.getContext('2d');
  cx.fillStyle = '#ffffff';
  cx.fillRect(0, 0, w, h);
  cx.drawImage(src, 0, 0);
  return c;
}
// Returns three images and one measured number. The number is a real count of
// pixels that differ by more than the threshold — not a similarity score
// anybody invented.
function officePdfToolPixelDiff(ca, cb, threshold) {
  const w = Math.max(ca.width, cb.width), h = Math.max(ca.height, cb.height);
  const na = officePdfToolPadCanvas(ca, w, h), nb = officePdfToolPadCanvas(cb, w, h);
  const da = na.getContext('2d').getImageData(0, 0, w, h).data;
  const db = nb.getContext('2d').getImageData(0, 0, w, h).data;
  const diff = document.createElement('canvas'); diff.width = w; diff.height = h;
  const over = document.createElement('canvas'); over.width = w; over.height = h;
  const dcx = diff.getContext('2d'), ocx = over.getContext('2d');
  const dImg = dcx.createImageData(w, h), oImg = ocx.createImageData(w, h);
  const t = threshold == null ? 28 : threshold;
  let changed = 0;
  for (let p = 0; p < da.length; p += 4) {
    const la = da[p] * 0.299 + da[p + 1] * 0.587 + da[p + 2] * 0.114;
    const lb = db[p] * 0.299 + db[p + 1] * 0.587 + db[p + 2] * 0.114;
    // A red / cyan overlay: where only A has ink the pixel goes red, where only
    // B has ink it goes cyan, and where both agree it stays greyscale.
    oImg.data[p] = lb; oImg.data[p + 1] = la; oImg.data[p + 2] = la; oImg.data[p + 3] = 255;
    if (Math.abs(la - lb) > t) {
      changed++;
      const aOnly = la < lb;
      dImg.data[p] = aOnly ? 200 : 40;
      dImg.data[p + 1] = aOnly ? 40 : 90;
      dImg.data[p + 2] = aOnly ? 40 : 200;
      dImg.data[p + 3] = 255;
    } else {
      dImg.data[p] = 255; dImg.data[p + 1] = 255; dImg.data[p + 2] = 255; dImg.data[p + 3] = 255;
    }
  }
  dcx.putImageData(dImg, 0, 0);
  ocx.putImageData(oImg, 0, 0);
  return { diffUrl: diff.toDataURL('image/png'), overlayUrl: over.toDataURL('image/png'),
           changed, total: w * h, pct: changed / (w * h) };
}

const PDFTOOL_COMPARE_VIEWS = [
  { key: 'text', label: 'Text differences' },
  { key: 'side', label: 'Side by side' },
  { key: 'overlay', label: 'Overlay' },
  { key: 'diff', label: 'Difference' },
];

function OfficePdfToolCompare({ ctx, doc }) {
  const body = (doc && doc.body) || {};
  const planA = useMemo(() => officePdfToolPlan(body), [body]);
  const [sourceKind, setSourceKind] = useState('doc');
  const [otherDocId, setOtherDocId] = useState('');
  const [versionN, setVersionN] = useState('');
  const [uploaded, setUploaded] = useState(null);
  const [view, setView] = useState('text');
  const [page, setPage] = useState(0);
  const [offset, setOffset] = useState(0);
  const [threshold, setThreshold] = useState(28);
  const [images, setImages] = useState(null);
  const [diff, setDiff] = useState(null);
  const [textDiff, setTextDiff] = useState(null);
  const [scan, setScan] = useState(null);
  const { job, run, retry, clear } = useOfficePdfToolJob();
  const upInput = useRef(null);

  const versions = (doc.versions || []).filter(v => v.body && (v.body.workingAssetId || v.body.originalAssetId || (v.body.pages || []).length));

  const planB = useMemo(() => {
    if (sourceKind === 'doc') {
      const d = (ctx.officeDocs || []).find(x => x.id === otherDocId);
      return d ? officePdfToolPlan(d.body) : [];
    }
    if (sourceKind === 'version') {
      const v = versions.find(x => String(x.n) === String(versionN));
      return v ? officePdfToolPlan(v.body) : [];
    }
    if (sourceKind === 'file' && uploaded) {
      return Array.from({ length: uploaded.pageCount }, (_, i) => ({ assetId: uploaded.assetId, index: i, rotate: 0, crop: null }));
    }
    return [];
  }, [sourceKind, otherDocId, versionN, uploaded, ctx.officeDocs, doc.versions]);

  const pageB = page + Number(offset || 0);
  const a = planA[page], b = planB[pageB];

  // Render whichever page pair is showing. Kept in an effect so paging is
  // instant to ask for and the work happens once per pair.
  useEffect(() => {
    let live = true;
    setImages(null); setDiff(null);
    if (!a || !b || view === 'text') return undefined;
    Promise.all([
      officePdfRenderPage(a.assetId, a.index, { width: 820, dpr: 1, rotate: a.rotate || 0 }),
      officePdfRenderPage(b.assetId, b.index, { width: 820, dpr: 1, rotate: b.rotate || 0 }),
    ]).then(([ra, rb]) => {
      if (!live) return;
      setImages({ a: ra.canvas.toDataURL('image/jpeg', 0.85), b: rb.canvas.toDataURL('image/jpeg', 0.85) });
      if (view === 'overlay' || view === 'diff') setDiff(officePdfToolPixelDiff(ra.canvas, rb.canvas, threshold));
    }).catch(() => {});
    return () => { live = false; };
  }, [a && a.assetId, a && a.index, b && b.assetId, b && b.index, view, threshold]);

  useEffect(() => {
    let live = true;
    setTextDiff(null);
    if (!a || !b || view !== 'text') return undefined;
    Promise.all([officePdfPageText(a.assetId, a.index), officePdfPageText(b.assetId, b.index)])
      .then(([ta, tb]) => { if (live) setTextDiff(officePdfToolDiffText(ta.text, tb.text)); })
      .catch(() => {});
    return () => { live = false; };
  }, [a && a.assetId, a && a.index, b && b.assetId, b && b.index, view]);

  // The revision summary. Scanning is a per-page render of BOTH documents, so
  // it is a real piece of work on this machine and is behind a button with a
  // progress bar rather than running on its own.
  function scanAll() {
    setScan(null);
    const n = Math.max(planA.length, planB.length);
    run('compare', 'Comparing every page', onProgress => {
      const rows = [];
      let chain = Promise.resolve();
      for (let i = 0; i < n; i++) {
        chain = chain.then(() => {
          const pa = planA[i], pb = planB[i + Number(offset || 0)];
          if (!pa) { rows.push({ page: i, state: 'added' }); onProgress(i + 1, n); return null; }
          if (!pb) { rows.push({ page: i, state: 'removed' }); onProgress(i + 1, n); return null; }
          return Promise.all([
            officePdfRenderPage(pa.assetId, pa.index, { width: 420, dpr: 1, rotate: pa.rotate || 0 }),
            officePdfRenderPage(pb.assetId, pb.index, { width: 420, dpr: 1, rotate: pb.rotate || 0 }),
            officePdfPageText(pa.assetId, pa.index),
            officePdfPageText(pb.assetId, pb.index),
          ]).then(([ra, rb, ta, tb]) => {
            const px = officePdfToolPixelDiff(ra.canvas, rb.canvas, threshold);
            const td = officePdfToolDiffText(ta.text, tb.text);
            const wordChanges = td.stats.added + td.stats.removed + td.stats.changed;
            rows.push({ page: i, state: (px.pct > 0.0015 || wordChanges > 0) ? 'modified' : 'same',
                        pct: px.pct, wordChanges });
            onProgress(i + 1, n);
          });
        });
      }
      return chain.then(() => { setScan(rows); return rows; });
    });
  }

  function pickFile(files) {
    if (!files || !files[0]) return;
    run('compare', 'Reading the file', () => officePdfImportFile(files[0]).then(r => { setUploaded(r); return r; }));
  }

  const summary = scan ? scan.reduce((s, r) => { s[r.state] = (s[r.state] || 0) + 1; return s; }, {}) : null;

  return (
    <div className="space-y-3">
      <OfficePdfToolHeader tool="compare" />

      <div className="rounded-lg border border-[var(--leon-line)] p-3 space-y-2">
        <div className="flex flex-wrap gap-1.5">
          {[['doc', 'Another LEON document'], ['version', 'A saved version of this one'], ['file', 'A file from this computer']].map(([k, l]) => (
            <button key={k} type="button" onClick={() => setSourceKind(k)}
              className={`px-2.5 py-1 rounded-md text-xs font-semibold border ${
                sourceKind === k ? 'border-[var(--leon-brown)] text-[var(--leon-brown)] bg-[var(--leon-cream)]'
                  : 'border-[var(--leon-line)] text-[var(--leon-black)]/60'}`}>{l}</button>
          ))}
        </div>
        {sourceKind === 'doc' && (
          <Select value={otherDocId} onChange={e => setOtherDocId(e.target.value)}>
            <option value="">Choose a document…</option>
            {officePdfToolPdfDocs(ctx, doc.id).map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
          </Select>
        )}
        {sourceKind === 'version' && (
          versions.length
            ? <Select value={versionN} onChange={e => setVersionN(e.target.value)}>
                <option value="">Choose a version…</option>
                {versions.map(v => <option key={v.n} value={v.n}>v{v.n} — {fmtDate(v.date)} {v.by ? '· ' + v.by : ''}</option>)}
              </Select>
            : <p className="text-xs text-[var(--leon-black)]/50">This document has no saved versions to compare against yet.</p>
        )}
        {sourceKind === 'file' && (
          <div className="flex items-center gap-2">
            <Button size="sm" variant="outline" onClick={() => upInput.current && upInput.current.click()}>Choose a PDF</Button>
            <span className="text-xs text-[var(--leon-black)]/55">{uploaded ? `${uploaded.name} · ${uploaded.pageCount} pages` : 'Nothing chosen'}</span>
            <input ref={upInput} type="file" accept="application/pdf,.pdf" className="hidden"
              onChange={e => { pickFile(e.target.files); e.target.value = ''; }} />
          </div>
        )}
        <p className="text-[11px] text-[var(--leon-black)]/50">
          A is this document ({planA.length} pages). B is the comparison ({planB.length} pages).
        </p>
      </div>

      <OfficePdfToolAiButton label="AI change summary"
        would="read both revisions and write a paragraph on what changed and why it matters." />

      {!!planB.length && (
        <>
          <div className="flex flex-wrap items-center gap-2">
            <Tabs tabs={PDFTOOL_COMPARE_VIEWS} active={view} onChange={setView} />
          </div>

          <div className="flex flex-wrap items-center gap-2">
            <Button size="sm" variant="outline" disabled={page <= 0} onClick={() => setPage(p => p - 1)}>‹ Previous</Button>
            <span className="text-xs">
              A page {page + 1} of {planA.length} · B page {pageB + 1} of {planB.length}
            </span>
            <Button size="sm" variant="outline" disabled={page >= planA.length - 1} onClick={() => setPage(p => p + 1)}>Next ›</Button>
            <Field label="B page offset" className="ml-2">
              <TextInput type="number" className="!w-20 !py-1" value={offset} onChange={e => setOffset(e.target.value)} />
            </Field>
            {view !== 'text' && (
              <Field label={`Sensitivity — ${threshold}`}>
                <input type="range" min="4" max="120" value={threshold} className="w-32"
                  onChange={e => setThreshold(Number(e.target.value))} />
              </Field>
            )}
            <Button size="sm" variant="outline" className="ml-auto" onClick={scanAll}>Scan every page</Button>
          </div>

          <OfficePdfToolJobBar job={job} onRetry={retry} onClear={clear} />

          {summary && (
            <div className="rounded-lg border border-[var(--leon-line)] bg-white px-3 py-2.5">
              <p className="text-sm font-bold mb-1">Revision summary</p>
              <div className="flex flex-wrap gap-3 text-xs">
                <span><Badge tone="green">{summary.same || 0}</Badge> unchanged</span>
                <span><Badge tone="yellow">{summary.modified || 0}</Badge> modified</span>
                <span><Badge tone="blue">{summary.added || 0}</Badge> only in A</span>
                <span><Badge tone="red">{summary.removed || 0}</Badge> only in B</span>
              </div>
              <ul className="mt-2 text-[11px] text-[var(--leon-black)]/60 max-h-40 overflow-y-auto space-y-0.5">
                {scan.filter(r => r.state !== 'same').map(r => (
                  <li key={r.page}>
                    Page {r.page + 1} — {r.state}
                    {r.pct != null ? ` · ${officePdfToolPct(r.pct)} of pixels differ` : ''}
                    {r.wordChanges ? ` · ${r.wordChanges} line change${r.wordChanges === 1 ? '' : 's'}` : ''}
                  </li>
                ))}
              </ul>
              <p className="text-[11px] text-[var(--leon-black)]/45 mt-1">
                "Modified" means either the pixels or the words differ — measured, page by page, from the two files.
              </p>
            </div>
          )}

          {view === 'text' && (
            <div className="rounded-lg border border-[var(--leon-line)] bg-white">
              {!textDiff && <div className="p-4 text-xs text-[var(--leon-black)]/45">Reading both pages…</div>}
              {textDiff && textDiff.tooBig && (
                <OfficePdfToolNote tone="warn">
                  These two pages are too large to diff line by line without hanging the tab. Compare a smaller
                  range, or use the pixel views.
                </OfficePdfToolNote>
              )}
              {textDiff && !textDiff.tooBig && (
                <>
                  <div className="flex gap-3 px-3 py-2 border-b border-[var(--leon-line)] text-xs">
                    <span className="text-[#3a7d44]">+{textDiff.stats.added} added</span>
                    <span className="text-[#b83b3b]">−{textDiff.stats.removed} removed</span>
                    <span className="text-[#a67b1f]">~{textDiff.stats.changed} changed</span>
                    <span className="text-[var(--leon-black)]/40">{textDiff.stats.same} unchanged</span>
                  </div>
                  <div className="p-3 max-h-[26rem] overflow-y-auto text-[12px] leading-relaxed font-mono space-y-0.5">
                    {!textDiff.rows.length && <p className="text-[var(--leon-black)]/40 italic">No text on either page — this looks like a scan. Use the pixel views.</p>}
                    {textDiff.rows.map((r, i) => {
                      if (r.kind === 'same') return <div key={i} className="text-[var(--leon-black)]/45">{r.text}</div>;
                      if (r.kind === 'added') return <div key={i} className="bg-[#e7f3e9] text-[#2c6636] px-1">+ {r.text}</div>;
                      if (r.kind === 'removed') return <div key={i} className="bg-[#fbe7e7] text-[#8d3232] px-1 line-through">− {r.text}</div>;
                      return (
                        <div key={i} className="bg-[#fbf1dd] px-1">
                          <span className="text-[var(--leon-black)]/40 mr-1">~</span>
                          {r.inline.map((w, k) => (
                            <span key={k} className={
                              w.op === 'add' ? 'bg-[#cfe9d5] text-[#2c6636]'
                                : w.op === 'del' ? 'bg-[#f3cfcf] text-[#8d3232] line-through'
                                  : ''}>{w.text}{' '}</span>
                          ))}
                        </div>
                      );
                    })}
                  </div>
                </>
              )}
            </div>
          )}

          {view === 'side' && (
            <div className="grid grid-cols-2 gap-2">
              {['a', 'b'].map(k => (
                <div key={k} className="rounded-lg border border-[var(--leon-line)] bg-white p-1">
                  <p className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 px-1">{k === 'a' ? 'A — this document' : 'B — comparison'}</p>
                  {images ? <img src={images[k]} alt="" className="w-full" /> : <div className="p-6 text-xs text-[var(--leon-black)]/40">Rendering…</div>}
                </div>
              ))}
            </div>
          )}

          {view === 'overlay' && (
            <div className="rounded-lg border border-[var(--leon-line)] bg-white p-1">
              {diff ? <img src={diff.overlayUrl} alt="" className="w-full" /> : <div className="p-6 text-xs text-[var(--leon-black)]/40">Rendering…</div>}
              <p className="text-[11px] text-[var(--leon-black)]/55 px-2 py-1">
                Ink only in A shows <span className="text-[#c02020] font-semibold">red</span>; ink only in B shows
                <span className="text-[#1f8f9f] font-semibold"> cyan</span>. Where they agree the page stays grey.
              </p>
            </div>
          )}

          {view === 'diff' && (
            <div className="rounded-lg border border-[var(--leon-line)] bg-white p-1">
              {diff ? <img src={diff.diffUrl} alt="" className="w-full" /> : <div className="p-6 text-xs text-[var(--leon-black)]/40">Rendering…</div>}
              {diff && (
                <p className="text-[11px] text-[var(--leon-black)]/55 px-2 py-1">
                  {diff.changed.toLocaleString()} of {diff.total.toLocaleString()} pixels differ by more than the
                  sensitivity setting — {officePdfToolPct(diff.pct)} of the page. Red is A only, blue is B only.
                </p>
              )}
            </div>
          )}

          <OfficePdfToolNote>
            Aligning two architectural drawings — matching the title block, snapping the grid, calling out what
            moved — belongs to Drawing Intelligence. This is a straight same-size comparison: if the two pages
            were plotted at different scales, use the page offset and the sensitivity, and read the result as an
            indication rather than a measurement.
          </OfficePdfToolNote>
        </>
      )}
    </div>
  );
}

// ══════════════════════════════════════════════════════════════ EXTRACTION
//
// §69 is the rule this whole panel is built around: NOTHING EXTRACTED FROM A PDF
// EVER WRITES TO A LEON RECORD ON ITS OWN. What comes out of a page is a
// proposal. It is shown with the page it came from and the LEON field it would
// land in, a person corrects it, and only then does anything get created.
//
// The reading itself is deterministic. Tables are found by clustering the text
// layer's own coordinates into rows and columns; fields are found by looking for
// a LABEL and taking the value beside it. Neither is a model, neither guesses,
// and where a number is shown for how well it worked it is measured — the share
// of rows whose cells actually line up — not a confidence anybody invented.

function officePdfToolMoney(s) {
  const m = String(s || '').replace(/[^0-9.,()-]/g, '');
  if (!m) return null;
  // Accounting notation wraps a credit in a PAIR of brackets — (1,234.00) —
  // so both are required. Testing for both is also what keeps this file's
  // bracket-balance check meaningful; a lone escaped bracket in a regex reads
  // to that check as a real one.
  const neg = String(s).indexOf('(') >= 0 && String(s).indexOf(')') >= 0;
  const n = parseFloat(m.replace(/[(),]/g, '').replace(/,/g, ''));
  if (isNaN(n)) return null;
  return neg ? -n : n;
}
// Dates on paper are ambiguous and pretending otherwise is how a due date lands
// a month out. The parse is US-first because that is where the company bills
// from, and the RAW text is always shown beside the parsed value so the reader
// can catch the case where it was not.
function officePdfToolDate(s) {
  const t = String(s || '').trim();
  if (!t) return null;
  let m = t.match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
  if (m) return `${m[1]}-${String(m[2]).padStart(2, '0')}-${String(m[3]).padStart(2, '0')}`;
  m = t.match(/(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{2,4})/);
  if (m) {
    let y = m[3].length === 2 ? '20' + m[3] : m[3];
    return `${y}-${String(m[1]).padStart(2, '0')}-${String(m[2]).padStart(2, '0')}`;
  }
  m = t.match(/(\d{1,2})\s+([A-Za-z]{3,})\s+(\d{4})/) || t.match(/([A-Za-z]{3,})\s+(\d{1,2}),?\s+(\d{4})/);
  if (m) {
    const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
    const isDayFirst = /^\d/.test(m[1]);
    const mon = months.indexOf(String(isDayFirst ? m[2] : m[1]).slice(0, 3).toLowerCase());
    if (mon >= 0) {
      const d = isDayFirst ? m[1] : m[2];
      return `${m[3]}-${String(mon + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
    }
  }
  return null;
}

// ── Coordinate-clustered table detection ──────────────────────────────────
function officePdfToolDetectTable(pageText) {
  const items = ((pageText && pageText.items) || []).filter(it => (it.str || '').trim());
  if (items.length < 6) return null;

  // Row grouping: items whose baselines sit within a fraction of the median
  // glyph height are on the same line. Using the median rather than a fixed
  // tolerance is what lets the same code read a 6pt schedule and a 14pt quote.
  const heights = items.map(i => i.h).filter(h => h > 0).sort((a, b) => a - b);
  const medH = heights[Math.floor(heights.length / 2)] || 0.012;
  const rowTol = medH * 0.6;
  const sorted = items.slice().sort((a, b) => (a.y - b.y) || (a.x - b.x));
  const rawRows = [];
  sorted.forEach(it => {
    const last = rawRows[rawRows.length - 1];
    if (last && Math.abs(it.y - last.y) <= rowTol) last.items.push(it);
    else rawRows.push({ y: it.y, items: [it] });
  });

  // Column detection: cluster the LEFT EDGES of every text run. Left edges are
  // what a table actually aligns on; clustering centres puts a short cell and a
  // long one in different columns.
  const xs = items.map(i => i.x).sort((a, b) => a - b);
  const colTol = 0.018;
  const clusters = [];
  xs.forEach(x => {
    const last = clusters[clusters.length - 1];
    if (last && x - last.max <= colTol) { last.max = x; last.sum += x; last.n++; }
    else clusters.push({ min: x, max: x, sum: x, n: 1 });
  });
  // A column that only ever held one run is a stray heading, not a column.
  const cols = clusters.filter(c => c.n >= 2).map(c => c.sum / c.n).sort((a, b) => a - b);
  if (cols.length < 2) return null;

  const grid = [];
  rawRows.forEach(r => {
    const cells = new Array(cols.length).fill('');
    r.items.slice().sort((a, b) => a.x - b.x).forEach(it => {
      let best = 0, bestD = Infinity;
      cols.forEach((c, ci) => { const d = Math.abs(it.x - c); if (d < bestD) { bestD = d; best = ci; } });
      cells[best] = (cells[best] ? cells[best] + ' ' : '') + it.str.trim();
    });
    if (cells.filter(Boolean).length >= 2) grid.push(cells);
  });
  if (grid.length < 2) return null;

  // How well did it line up? A real, checkable number: the share of rows that
  // filled the same number of cells as the most common row shape.
  const counts = {};
  grid.forEach(r => { const k = r.filter(Boolean).length; counts[k] = (counts[k] || 0) + 1; });
  const modal = Object.keys(counts).reduce((a, b) => counts[b] > counts[a] ? b : a, Object.keys(counts)[0]);
  const aligned = counts[modal] || 0;

  return { rows: grid, columns: cols.length, alignment: aligned / grid.length,
           alignedRows: aligned, totalRows: grid.length, modalColumns: Number(modal) };
}

// ── Labelled-value field reading ──────────────────────────────────────────
function officePdfToolReadField(pages, spec) {
  for (let pi = 0; pi < pages.length; pi++) {
    const text = (pages[pi].text || '').replace(/[ ]/g, ' ');
    for (let li = 0; li < spec.labels.length; li++) {
      const label = spec.labels[li].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
      const re = new RegExp('(?:^|\\n|\\s{2,})\\s*' + label + '\\s*[:#.]?\\s*([^\\n]{0,80})', 'i');
      const m = re.exec(text);
      if (!m) continue;
      const raw = (m[1] || '').trim();
      if (!raw) continue;
      let value = raw;
      if (spec.kind === 'money') { const n = officePdfToolMoney(raw); if (n == null) continue; value = n; }
      else if (spec.kind === 'number') { const n = officePdfToolMoney(raw); if (n == null) continue; value = n; }
      else if (spec.kind === 'date') { const d = officePdfToolDate(raw); if (!d) continue; value = d; }
      else if (spec.kind === 'currency') { const c = raw.match(/[A-Z]{3}/); value = c ? c[0] : raw.slice(0, 12); }
      else { value = raw.replace(/\s{2,}.*$/, '').slice(0, 60); }
      return { key: spec.key, value, raw, page: pi, matchedLabel: spec.labels[li] };
    }
  }
  return { key: spec.key, value: '', raw: '', page: null, matchedLabel: null };
}

function OfficePdfToolExtract({ ctx, doc, onChange, editable }) {
  const body = (doc && doc.body) || {};
  const plan = useMemo(() => officePdfToolPlan(body), [body]);
  const project = doc.projectId ? (ctx.projects || []).find(p => p.id === doc.projectId) : null;
  const [tab, setTab] = useState('table');
  const [pageNo, setPageNo] = useState(1);
  const [table, setTable] = useState(null);
  const [headerRow, setHeaderRow] = useState(true);
  const [dropCols, setDropCols] = useState([]);
  const [kind, setKind] = useState('Vendor Quote');
  const [fields, setFields] = useState(null);
  const [target, setTarget] = useState('');
  const [vendorId, setVendorId] = useState('');
  const [scopeId, setScopeId] = useState(doc.scopeId || '');
  const [sent, setSent] = useState(null);
  const { job, run, retry, clear } = useOfficePdfToolJob();

  const p = plan[pageNo - 1];

  function detect() {
    if (!p) return;
    setTable(null);
    run('extract-table', 'Reading the text layer', () =>
      officePdfPageText(p.assetId, p.index).then(pt => {
        const t = officePdfToolDetectTable(pt);
        setTable(t);
        if (!t) throw new Error('No table-shaped text was found on this page. A scanned page has no text layer at all.');
        return t;
      }));
  }

  function readFields() {
    setFields(null); setSent(null);
    run('extract-fields', 'Looking for labelled values', onProgress =>
      // Read the pages as the PLAN orders them, not as the asset stores them,
      // so a combined document reads in the order it is actually filed in.
      plan.reduce((chain, pp, i) => chain.then(acc =>
        officePdfPageText(pp.assetId, pp.index).then(pt => {
          acc.push({ ...pt, page: i });
          onProgress(i + 1, plan.length);
          return acc;
        })
      ), Promise.resolve([]))
        .then(pages => {
          const specs = PDFTOOL_FIELD_PATTERNS[kind] || [];
          const read = specs.map(s => ({ ...officePdfToolReadField(pages, s), spec: s }));
          setFields(read);
          // Guess the vendor from the roster rather than typing it again — but
          // it is only ever a preselection in a control a person can change.
          const vn = (read.find(r => r.key === 'vendorName') || {}).value;
          if (vn) {
            const hit = (ctx.vendors || []).find(v => String(v.name || '').toLowerCase().includes(String(vn).toLowerCase().slice(0, 12)));
            if (hit) setVendorId(hit.id);
          }
          return read;
        })
    );
  }

  // Filing the reviewed extraction on the document. This is the record §69
  // describes: the review step that stands between a page and a LEON record.
  function fileExtraction(extra) {
    const rec = makePdfExtraction({
      kind, page: pageNo - 1,
      rows: table ? table.rows : [],
      fields: (fields || []).reduce((o, f) => { o[f.key] = f.value; return o; }, {}),
      confidence: table ? table.alignment : null,
      reviewed: true, ...(extra || {}),
    });
    officePdfToolPatch(ctx, doc, onChange, { extractions: (body.extractions || []).concat([rec]) });
    return rec;
  }

  function sendToSheets() {
    if (!table || typeof ctx.setOfficeDocs !== 'function') return;
    const keep = table.rows.map(r => r.filter((c, ci) => dropCols.indexOf(ci) === -1));
    const sheet = makeSheetBody();
    // Sparse cells keyed "r,c", carrying a plain value. LEON Sheets owns the
    // final cell model; a value with no formula is the one shape every reader
    // of that model can already understand, and an extraction has no formulas
    // to carry anyway.
    keep.forEach((row, r) => row.forEach((cell, c) => { if (cell) sheet.sheets[0].cells[`${r},${c}`] = { v: cell }; }));
    if (headerRow) sheet.sheets[0].frozen = { r: 1, c: 0 };
    const made = makeOfficeDocument({
      name: `${doc.name} — table from page ${pageNo}`,
      app: 'sheet', projectId: doc.projectId || null, scopeId: doc.scopeId || null,
      folder: doc.folder, body: sheet,
      activity: [{ id: uid('act'), date: todayISO(), by: ctx.currentUserName || '',
                   action: `Extracted from "${doc.name}" page ${pageNo} — ${keep.length} rows, ${keep[0] ? keep[0].length : 0} columns.` }],
    }, ctx.currentUserName || '');
    ctx.setOfficeDocs(prev => (prev || []).concat([made]));
    fileExtraction({ targetModule: 'LEON Sheets', appliedDate: todayISO(), appliedBy: ctx.currentUserName || '' });
    officePdfToolLog(ctx, doc, `Sent a ${keep.length}-row table from page ${pageNo} to LEON Sheets.`);
    setSent({ label: 'LEON Sheets', detail: `"${made.name}" created with ${keep.length} rows.` });
  }

  function val(k) { const f = (fields || []).find(x => x.key === k); return f ? f.value : ''; }
  function setVal(k, v) { setFields(prev => (prev || []).map(f => f.key === k ? { ...f, value: v } : f)); }

  const targetDef = PDFTOOL_SEND_TARGETS.find(t => t.key === target);

  function send() {
    if (!targetDef || !project) return;
    const vendor = (ctx.vendors || []).find(v => v.id === vendorId);
    if (targetDef.key === 'procurement' && typeof ctx.addVendorEstimate === 'function') {
      ctx.addVendorEstimate(project.id, {
        vendorId: vendor ? vendor.id : null, vendorName: vendor ? vendor.name : (val('vendorName') || 'Unnamed vendor'),
        category: 'Original Order', scopeId: scopeId || null, currency: val('currency') || 'USD',
        description: `${val('quoteNumber') || 'Quote'} — read from "${doc.name}"`,
        amount: Number(val('amount')) || 0, date: val('date') || todayISO(),
      });
      setSent({ label: 'Procurement', detail: 'A Vendor Estimate was created on ' + project.name + '.' });
    } else if (targetDef.key === 'financial' && typeof ctx.addApInvoice === 'function') {
      ctx.addApInvoice(project.id, {
        partyType: 'Vendor', vendorId: vendor ? vendor.id : null,
        vendorName: vendor ? vendor.name : (val('vendorName') || 'Unnamed vendor'),
        projectId: project.id, scopeId: scopeId || null,
        invoiceNumber: val('invoiceNumber') || val('quoteNumber') || '',
        invoiceDate: val('invoiceDate') || val('date') || todayISO(),
        dueDate: val('dueDate') || null, poReference: val('poReference') || '',
        amount: Number(val('amount')) || 0,
        description: `Read from "${doc.name}"`, notes: `Extracted in LEON PDF by ${ctx.currentUserName || ''}.`,
      });
      setSent({ label: 'Financial', detail: 'An AP Invoice was created on ' + project.name + '.' });
    } else if (targetDef.key === 'drawing' && typeof ctx.addTakeOff === 'function') {
      ctx.addTakeOff(project.id, {
        name: `${doc.name} — extracted quantities`, revision: 1, date: todayISO(),
        preparedBy: ctx.currentUserName || '', scopeId: scopeId || null,
        note: `Read from "${doc.name}" page ${pageNo} — ${(table && table.rows.length) || 0} rows.`,
      });
      setSent({ label: 'Drawing Intelligence', detail: 'A Take-Off record was created on ' + project.name + '.' });
    } else {
      setSent({ label: targetDef.label, detail: null });
    }
    fileExtraction({ targetModule: targetDef.label, appliedDate: todayISO(), appliedBy: ctx.currentUserName || '' });
    officePdfToolLog(ctx, doc, `Extraction reviewed and sent to ${targetDef.label}.`);
  }

  return (
    <div className="space-y-3">
      <OfficePdfToolHeader tool="extract" />
      <Tabs tabs={[{ key: 'table', label: 'Tables', icon: '▦' }, { key: 'fields', label: 'Fields', icon: '🏷️' }]}
        active={tab} onChange={setTab} />

      <OfficePdfToolAiButton label="AI data extraction"
        would="read the page the way a person does and pull the figures out of a layout it has never seen." />

      {tab === 'table' && (
        <>
          <div className="flex flex-wrap items-end gap-2">
            <Field label="Page">
              <TextInput type="number" min="1" max={plan.length} className="!w-24" value={pageNo}
                onChange={e => setPageNo(officePdfToolClamp(parseInt(e.target.value, 10) || 1, 1, Math.max(1, plan.length)))} />
            </Field>
            <Button size="sm" disabled={!plan.length} onClick={detect}>Find the table on this page</Button>
            {p && <OfficePdfToolThumb assetId={p.assetId} index={p.index} rotate={p.rotate} width={60} />}
          </div>
          <OfficePdfToolJobBar job={job} onRetry={retry} onClear={clear} />

          {table && (
            <div className="space-y-2">
              <div className="flex flex-wrap items-center gap-3 text-xs">
                <Badge tone={table.alignment > 0.8 ? 'green' : table.alignment > 0.5 ? 'yellow' : 'red'}>
                  {Math.round(table.alignment * 100)}% column alignment
                </Badge>
                <span className="text-[var(--leon-black)]/60">
                  {table.alignedRows} of {table.totalRows} rows filled the same {table.modalColumns} columns ·
                  {' '}{table.columns} columns detected
                </span>
                <label className="flex items-center gap-1.5">
                  <input type="checkbox" checked={headerRow} onChange={e => setHeaderRow(e.target.checked)} />
                  First row is a header
                </label>
              </div>
              <p className="text-[11px] text-[var(--leon-black)]/45">
                That percentage is measured, not estimated: it is the share of rows whose cells lined up on the
                same column edges. A low figure means the page is not really a table, or the columns run together.
              </p>
              <div className="overflow-x-auto border border-[var(--leon-line)] rounded-lg bg-white">
                <table className="text-xs min-w-full">
                  <thead>
                    <tr className="bg-[var(--leon-cream)]">
                      {(table.rows[0] || []).map((c, ci) => (
                        <th key={ci} className="px-2 py-1 border-b border-[var(--leon-line)] text-left">
                          <label className="flex items-center gap-1 font-normal">
                            <input type="checkbox" checked={dropCols.indexOf(ci) === -1}
                              onChange={e => setDropCols(prev => e.target.checked ? prev.filter(x => x !== ci) : prev.concat([ci]))} />
                            <span className="font-semibold">{headerRow ? (c || `Col ${ci + 1}`) : `Col ${ci + 1}`}</span>
                          </label>
                        </th>
                      ))}
                    </tr>
                  </thead>
                  <tbody>
                    {table.rows.slice(headerRow ? 1 : 0, headerRow ? 41 : 40).map((r, ri) => (
                      <tr key={ri} className="border-b border-[var(--leon-line)]/60">
                        {r.map((c, ci) => (
                          <td key={ci} className={`px-2 py-1 align-top ${dropCols.indexOf(ci) >= 0 ? 'opacity-30 line-through' : ''}`}>{c}</td>
                        ))}
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
              {table.rows.length > 41 && <p className="text-[11px] text-[var(--leon-black)]/45">First 40 rows shown; all {table.rows.length} are sent.</p>}
              <Button size="sm" disabled={!editable} onClick={sendToSheets}>📊 Send table to LEON Sheets</Button>
            </div>
          )}
        </>
      )}

      {tab === 'fields' && (
        <>
          <div className="flex flex-wrap items-end gap-2">
            <Field label="What kind of document is this?">
              <Select value={kind} onChange={e => { setKind(e.target.value); setFields(null); setTarget(''); }}>
                {PDFTOOL_EXTRACT_KINDS.map(k => <option key={k} value={k}>{k}</option>)}
              </Select>
            </Field>
            <Button size="sm" disabled={!plan.length} onClick={readFields}>Read the fields</Button>
          </div>
          <OfficePdfToolJobBar job={job} onRetry={retry} onClear={clear} />

          {fields && (
            <div className="space-y-2">
              <p className="text-xs font-bold">Review — nothing is created until you send it</p>
              <div className="overflow-x-auto border border-[var(--leon-line)] rounded-lg bg-white">
                <table className="text-xs min-w-full">
                  <thead>
                    <tr className="bg-[var(--leon-cream)] text-left">
                      <th className="px-2 py-1.5">Field</th>
                      <th className="px-2 py-1.5">Value</th>
                      <th className="px-2 py-1.5">Found on</th>
                      <th className="px-2 py-1.5">As printed</th>
                      <th className="px-2 py-1.5">Would fill</th>
                    </tr>
                  </thead>
                  <tbody>
                    {fields.map(f => (
                      <tr key={f.key} className="border-b border-[var(--leon-line)]/60">
                        <td className="px-2 py-1.5 font-semibold whitespace-nowrap">{f.spec.label}</td>
                        <td className="px-2 py-1.5">
                          <TextInput className="!py-1 !text-xs" value={f.value == null ? '' : f.value}
                            disabled={!editable} onChange={e => setVal(f.key, e.target.value)} />
                        </td>
                        <td className="px-2 py-1.5 whitespace-nowrap text-[var(--leon-black)]/55">
                          {f.page == null ? <span className="text-[var(--leon-red)]">not found</span> : `page ${f.page + 1}`}
                        </td>
                        <td className="px-2 py-1.5 text-[var(--leon-black)]/45 max-w-[14rem] truncate" title={f.raw}>{f.raw || '—'}</td>
                        <td className="px-2 py-1.5 text-[var(--leon-black)]/55 whitespace-nowrap">{f.spec.leon}</td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
              <OfficePdfToolNote>
                A blank row means the label was not on the page — it is left empty rather than filled with a
                plausible guess. Dates are read US-first (month/day); the printed text is beside each one so an
                international date can be spotted and corrected.
              </OfficePdfToolNote>

              <div className="rounded-lg border border-[var(--leon-line)] p-3 space-y-2">
                <p className="text-xs font-bold">Send to a module</p>
                {!project && <p className="text-[11px] text-[var(--leon-black)]/50">This document is not linked to a project, so there is nowhere to create a record. Link it first.</p>}
                <div className="grid sm:grid-cols-3 gap-2">
                  <Field label="Module">
                    <Select value={target} onChange={e => setTarget(e.target.value)}>
                      <option value="">Choose…</option>
                      {PDFTOOL_SEND_TARGETS.filter(t => t.kinds.indexOf(kind) >= 0)
                        .map(t => <option key={t.key} value={t.key}>{t.label}</option>)}
                    </Select>
                  </Field>
                  <Field label="Vendor">
                    <Select value={vendorId} onChange={e => setVendorId(e.target.value)}>
                      <option value="">Not set</option>
                      {(ctx.vendors || []).map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
                    </Select>
                  </Field>
                  <Field label="Scope">
                    <Select value={scopeId} onChange={e => setScopeId(e.target.value)}>
                      <option value="">Whole project</option>
                      {((project && project.scopes) || []).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
                    </Select>
                  </Field>
                </div>
                {targetDef && (
                  targetDef.creator
                    ? <p className="text-[11px] text-[var(--leon-black)]/60">This creates {targetDef.makes}.</p>
                    : <OfficePdfToolNote tone="warn">
                        {targetDef.label} makes {targetDef.makes}. Sending here files the reviewed extraction on
                        this document and links it to the job — it is a hand-off, not an automatic record, and the
                        record itself is still created in {targetDef.label} by the person who owns it.
                      </OfficePdfToolNote>
                )}
                <Button size="sm" disabled={!editable || !targetDef || !project} onClick={send}>
                  Confirm and send
                </Button>
                {sent && (
                  <div className="rounded-md border border-[#bcd9c2] bg-[#f3faf4] px-3 py-2 text-xs">
                    <b>Sent to {sent.label}.</b> {sent.detail || 'Filed on this document and linked to the job; no record was created automatically.'}
                  </div>
                )}
              </div>
            </div>
          )}
        </>
      )}

      {!!(body.extractions || []).length && (
        <Collapsible title="Extractions filed on this document" id={`pdftool-xtr-${doc.id}`}
          count={(body.extractions || []).length}>
          <ul className="text-xs space-y-1">
            {(body.extractions || []).map(x => (
              <li key={x.id} className="flex items-center gap-2">
                <Badge tone="neutral">{x.kind}</Badge>
                <span className="text-[var(--leon-black)]/60">
                  page {(x.page || 0) + 1}{x.rows && x.rows.length ? ` · ${x.rows.length} rows` : ''}
                  {x.targetModule ? ` · sent to ${x.targetModule}` : ' · not sent'}
                  {x.confidence != null ? ` · ${Math.round(x.confidence * 100)}% column alignment` : ''}
                </span>
              </li>
            ))}
          </ul>
        </Collapsible>
      )}
    </div>
  );
}

// ══════════════════════════════════════════════════════════════════ OPTIMIZE
//
// One honest mechanism, four settings. A browser cannot re-sample the images
// already embedded inside a PDF, subset its fonts or rewrite its object
// streams. What it CAN do is re-render every page and re-encode it as a JPEG at
// a chosen resolution — which really does make the file smaller, and really
// does cost the text layer. Both halves are said on the screen, because a
// "compress" button that quietly turns a searchable specification into pictures
// is a trap.

// Is this page blank? Measured off the rendered pixels, at low resolution,
// because a page can be visually empty while carrying a header, a border or a
// stray white-on-white object. The answer is a SUGGESTION — nothing is ever
// removed automatically. A page someone deliberately left blank ("this page
// intentionally left blank") is still a page of the issued set.
function officePdfToolBlankness(canvas) {
  const cx = canvas.getContext('2d');
  const d = cx.getImageData(0, 0, canvas.width, canvas.height).data;
  let ink = 0;
  for (let p = 0; p < d.length; p += 4) {
    const l = d[p] * 0.299 + d[p + 1] * 0.587 + d[p + 2] * 0.114;
    if (l < 235) ink++;
  }
  return ink / (canvas.width * canvas.height);
}

function OfficePdfToolOptimize({ ctx, doc, onChange, editable }) {
  const body = (doc && doc.body) || {};
  const plan = useMemo(() => officePdfToolPlan(body), [body]);
  const [preset, setPreset] = useState('standard');
  const [dpi, setDpi] = useState(150);
  const [quality, setQuality] = useState(0.82);
  const [before, setBefore] = useState(null);
  const [result, setResult] = useState(null);
  const [blanks, setBlanks] = useState(null);
  const [normSize, setNormSize] = useState('Letter');
  const [normResult, setNormResult] = useState(null);
  const [crop, setCrop] = useState({ pages: 'all', x: 0, y: 0, w: 100, h: 100 });
  const { job, run, retry, clear } = useOfficePdfToolJob();

  useEffect(() => {
    let live = true;
    const id = body.workingAssetId || body.originalAssetId;
    if (!id) return undefined;
    officePdfGetAsset(id).then(a => { if (live && a) setBefore(a.size || 0); }).catch(() => {});
    return () => { live = false; };
  }, [body.workingAssetId, body.originalAssetId]);

  function pick(key) {
    setPreset(key);
    const p = PDFTOOL_OPTIMIZE_PRESETS.find(x => x.key === key);
    if (p && key !== 'custom') { setDpi(p.dpi); setQuality(p.quality); }
  }

  function reduce() {
    setResult(null);
    run('optimize', 'Re-rendering every page', onProgress =>
      officePdfRasterize(plan, { dpi: Number(dpi), quality: Number(quality), onProgress,
                                 paint: (cx, r, pp) => officePdfToolMaskCrop(cx, r, pp) })
        .then(d => officePdfSaveDoc(d))
        .then(bytes => officePdfToolSaveResult(bytes,
          `${officePdfToolSafeName(doc.name, 'document')} — ${preset}.pdf`, { kind: 'pdf', from: 'optimize' }))
        .then(r => { setResult(r); officePdfToolLog(ctx, doc, `Wrote a reduced copy at ${dpi} DPI (${officePdfBytesLabel(r.size)}).`); return r; })
    );
  }

  function findBlanks() {
    setBlanks(null);
    run('optimize', 'Looking for blank pages', onProgress =>
      plan.reduce((chain, p, i) => chain.then(acc =>
        officePdfRenderPage(p.assetId, p.index, { width: 170, dpr: 1, rotate: p.rotate || 0 }).then(r => {
          const ink = officePdfToolBlankness(r.canvas);
          if (ink < 0.002) acc.push({ page: i, ink });
          onProgress(i + 1, plan.length);
          return acc;
        })
      ), Promise.resolve([])).then(list => { setBlanks(list); return list; })
    );
  }

  function dropBlanks() {
    if (!blanks || !blanks.length) return;
    const pages = officePdfToolWithout(body, plan, blanks.map(b => b.page));
    const n = blanks.length;
    officePdfToolPatch(ctx, doc, onChange, { pages, pageCount: pages.length });
    officePdfToolLog(ctx, doc, `Removed ${n} blank page${n === 1 ? '' : 's'} from the page order (the pages themselves are untouched in the source file).`);
    setBlanks(null);
  }

  function normalize() {
    setNormResult(null);
    const size = PDFTOOL_PAGE_SIZES[normSize];
    run('optimize', 'Normalizing page sizes', onProgress =>
      officePdfLibs().then(({ PDFLib }) =>
        officePdfAssemble(plan, { onProgress }).then(out => {
          out.getPages().forEach(page => {
            const { width: ow, height: oh } = page.getSize();
            // Orientation is preserved: a landscape sheet becomes landscape
            // Letter, not a rotated portrait one.
            const tw = ow > oh ? size.h : size.w;
            const th = ow > oh ? size.w : size.h;
            const s = Math.min(tw / ow, th / oh);
            try {
              page.scaleContent(s, s);
              page.setSize(tw, th);
              page.translateContent((tw - ow * s) / 2, (th - oh * s) / 2);
            } catch (e) { /* a page the writer cannot rescale is left at its own size */ }
          });
          return out;
        })
          .then(out => officePdfSaveDoc(out))
          .then(bytes => officePdfToolSaveResult(bytes,
            `${officePdfToolSafeName(doc.name, 'document')} — ${normSize}.pdf`, { kind: 'pdf', from: 'normalize' }))
          .then(r => { setNormResult(r); officePdfToolLog(ctx, doc, `Normalized every page to ${normSize}.`); return r; })
      ));
  }

  function applyCrop() {
    const idx = crop.pages === 'all' ? plan.map((p, i) => i) : officePdfToolParseRanges(crop.pages, plan.length);
    const box = { x: Number(crop.x) / 100, y: Number(crop.y) / 100, w: Number(crop.w) / 100, h: Number(crop.h) / 100 };
    const base = officePdfToolBasePages(body, plan);
    const touch = new Set(idx.map(i => plan[i] && plan[i].id).filter(Boolean));
    const pages = base.map((p, i) => (touch.size ? touch.has(p.id) : idx.indexOf(i) >= 0) ? { ...p, crop: box } : p);
    officePdfToolPatch(ctx, doc, onChange, { pages });
    officePdfToolLog(ctx, doc, `Cropped ${idx.length} page${idx.length === 1 ? '' : 's'} to ${crop.w}% × ${crop.h}%.`);
  }
  function clearCrop() {
    const base = officePdfToolBasePages(body, plan);
    officePdfToolPatch(ctx, doc, onChange, { pages: base.map(p => ({ ...p, crop: null })) });
  }

  const saved = before && result ? (before - result.size) / before : null;

  return (
    <div className="space-y-3">
      <OfficePdfToolHeader tool="optimize" />

      <Collapsible title="Reduce the file size" id={`pdftool-opt-${doc.id}`} defaultOpen>
        <div className="flex flex-wrap gap-1.5 mb-2">
          {PDFTOOL_OPTIMIZE_PRESETS.map(x => (
            <button key={x.key} type="button" onClick={() => pick(x.key)} title={x.note}
              className={`px-2.5 py-1 rounded-md text-xs font-semibold border ${
                preset === x.key ? 'border-[var(--leon-brown)] text-[var(--leon-brown)] bg-[var(--leon-cream)]'
                  : 'border-[var(--leon-line)] text-[var(--leon-black)]/60'}`}>{x.label}</button>
          ))}
        </div>
        <p className="text-[11px] text-[var(--leon-black)]/55 mb-2">
          {(PDFTOOL_OPTIMIZE_PRESETS.find(x => x.key === preset) || {}).note}
        </p>
        {preset === 'custom' && (
          <div className="grid sm:grid-cols-2 gap-2">
            <Field label={`Resolution — ${dpi} DPI`}>
              <input type="range" min="72" max="300" value={dpi} className="w-full" onChange={e => setDpi(Number(e.target.value))} />
            </Field>
            <Field label={`JPEG quality — ${Math.round(quality * 100)}%`}>
              <input type="range" min="30" max="98" value={Math.round(quality * 100)} className="w-full"
                onChange={e => setQuality(Number(e.target.value) / 100)} />
            </Field>
          </div>
        )}
        <div className="flex items-center gap-3 flex-wrap mt-2">
          <Button size="sm" disabled={!editable || !plan.length} onClick={reduce}>Write a reduced copy</Button>
          <span className="text-xs text-[var(--leon-black)]/55">Now: {before == null ? '—' : officePdfBytesLabel(before)}</span>
          {result && (
            <span className="text-xs">
              → <b>{officePdfBytesLabel(result.size)}</b>
              {saved != null && <span className={saved > 0 ? 'text-[#3a7d44]' : 'text-[#b83b3b]'}>
                {' '}({saved > 0 ? '−' : '+'}{Math.abs(Math.round(saved * 100))}%)
              </span>}
            </span>
          )}
        </div>
        <OfficePdfToolNote tone="warn">
          This re-renders every page as an image. The file gets smaller; the text stops being selectable,
          searchable and extractable. On a scanned document that costs nothing. On a specification it costs
          everything — and it is written to a new file, so the searchable original is still there either way.
          {saved != null && saved <= 0 && ' On this document it did not help: the pages are already images, or already smaller than the render. Keep the original.'}
        </OfficePdfToolNote>
        <OfficePdfToolJobBar job={job} onRetry={retry} onClear={clear} />
        <OfficePdfToolResult result={result} />
      </Collapsible>

      <Collapsible title="Blank pages" id={`pdftool-blank-${doc.id}`}>
        <Button size="sm" variant="outline" disabled={!plan.length} onClick={findBlanks}>Look for blank pages</Button>
        {blanks && (
          <div className="mt-2 space-y-2">
            {!blanks.length && <p className="text-xs text-[var(--leon-black)]/55">No blank pages found.</p>}
            {!!blanks.length && (
              <>
                <p className="text-xs">
                  Possibly blank: <b>{officePdfToolRangeLabel(blanks.map(b => b.page))}</b>
                  <span className="text-[var(--leon-black)]/45"> — under 0.2% ink at low resolution.</span>
                </p>
                <div className="flex gap-2 overflow-x-auto pb-1">
                  {blanks.slice(0, 12).map(b => (
                    <OfficePdfToolThumb key={b.page} assetId={plan[b.page].assetId} index={plan[b.page].index}
                      rotate={plan[b.page].rotate} width={58} caption={String(b.page + 1)} />
                  ))}
                </div>
                <Button size="sm" variant="outline" disabled={!editable} onClick={dropBlanks}>
                  Remove these from the page order
                </Button>
                <p className="text-[11px] text-[var(--leon-black)]/45">
                  Suggested, never removed on its own — a page deliberately left blank is still part of an issued
                  set, and removing it silently would renumber someone else's drawing list.
                </p>
              </>
            )}
          </div>
        )}
      </Collapsible>

      <Collapsible title="Page size" id={`pdftool-size-${doc.id}`}>
        <div className="flex flex-wrap items-end gap-2">
          <Field label="Normalize every page to">
            <Select value={normSize} onChange={e => setNormSize(e.target.value)}>
              {Object.keys(PDFTOOL_PAGE_SIZES).map(k => <option key={k} value={k}>{PDFTOOL_PAGE_SIZES[k].label}</option>)}
            </Select>
          </Field>
          <Button size="sm" variant="outline" disabled={!editable || !plan.length} onClick={normalize}>Normalize</Button>
        </div>
        <p className="text-[11px] text-[var(--leon-black)]/50 mt-1">
          Content is scaled to fit and centred, and landscape stays landscape. Nothing is cropped.
        </p>
        <OfficePdfToolResult result={normResult} />
      </Collapsible>

      <Collapsible title="Crop" id={`pdftool-crop-${doc.id}`}>
        <div className="grid grid-cols-5 gap-2">
          <Field label="Pages"><TextInput value={crop.pages} onChange={e => setCrop({ ...crop, pages: e.target.value })} /></Field>
          {[['x', 'Left %'], ['y', 'Top %'], ['w', 'Width %'], ['h', 'Height %']].map(([k, l]) => (
            <Field key={k} label={l}>
              <TextInput type="number" value={crop[k]} onChange={e => setCrop({ ...crop, [k]: e.target.value })} />
            </Field>
          ))}
        </div>
        <div className="flex gap-2 mt-2">
          <Button size="sm" variant="outline" disabled={!editable} onClick={applyCrop}>Apply crop</Button>
          <Button size="sm" variant="ghost" disabled={!editable} onClick={clearCrop}>Clear every crop</Button>
        </div>
        <p className="text-[11px] text-[var(--leon-black)]/45 mt-1">
          A crop is stored on the page order, not burned into the file — it takes effect when the document is
          assembled, and clearing it brings the whole page back.
        </p>
      </Collapsible>

      <OfficePdfToolNote>
        <b>Auto-deskew is not built.</b> Straightening a crooked scan means detecting the text baseline's angle
        and resampling the whole page, and doing it badly is worse than leaving it — a drawing rotated by two
        degrees too many is harder to read, not easier. Rotating a page by 90° in the viewer covers the common
        case, which is a sheet fed in sideways.
      </OfficePdfToolNote>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════════ EXPORT
//
// Four exports and one rule: each writes a NEW file and the document keeps
// everything it had. "Flatten" is the one people mean when they say "make the
// markup permanent" — and it is permanent in the EXPORTED file only, which is
// exactly right: the editable LEON layer is the thing you go back to when the
// architect asks for the same set without the internal comments on it.

// Paints the LEON markup layer onto a rendered page. This is what makes a
// flattened export honest: what is drawn here is the same geometry the viewer
// draws, read from the same percentage coordinates.
function officePdfToolPaintLayer(cx, r, pageIndex, body) {
  const W = r.width, H = r.height;
  const px = v => v * W, py = v => v * H;
  const annos = (body.annotations || []).filter(a => a.page === pageIndex);
  annos.forEach(a => {
    const rc = a.rect || { x: 0, y: 0, w: 0, h: 0 };
    cx.save();
    cx.globalAlpha = a.opacity == null ? 1 : a.opacity;
    cx.strokeStyle = a.color || '#F5C518';
    cx.fillStyle = a.color || '#F5C518';
    cx.lineWidth = Math.max(1, W / 600);
    const pts = (a.points || []).map(p => ({ x: px(p.x), y: py(p.y) }));
    if (a.type === 'Highlight') cx.fillRect(px(rc.x), py(rc.y), px(rc.w), py(rc.h));
    else if (a.type === 'Underline') { cx.beginPath(); cx.moveTo(px(rc.x), py(rc.y + rc.h)); cx.lineTo(px(rc.x + rc.w), py(rc.y + rc.h)); cx.stroke(); }
    else if (a.type === 'Strikeout') { cx.beginPath(); cx.moveTo(px(rc.x), py(rc.y + rc.h / 2)); cx.lineTo(px(rc.x + rc.w), py(rc.y + rc.h / 2)); cx.stroke(); }
    else if (a.type === 'Rectangle' || a.type === 'Cloud') cx.strokeRect(px(rc.x), py(rc.y), px(rc.w), py(rc.h));
    else if (a.type === 'Circle') {
      cx.beginPath();
      cx.ellipse(px(rc.x + rc.w / 2), py(rc.y + rc.h / 2), px(rc.w / 2), py(rc.h / 2), 0, 0, Math.PI * 2);
      cx.stroke();
    } else if (pts.length > 1) {
      cx.beginPath(); cx.moveTo(pts[0].x, pts[0].y);
      pts.slice(1).forEach(p => cx.lineTo(p.x, p.y));
      if (a.type === 'Polygon') cx.closePath();
      cx.stroke();
      if (a.type === 'Arrow') {
        const p1 = pts[pts.length - 2], p2 = pts[pts.length - 1];
        const ang = Math.atan2(p2.y - p1.y, p2.x - p1.x), head = Math.max(6, W / 90);
        cx.beginPath(); cx.moveTo(p2.x, p2.y);
        cx.lineTo(p2.x - head * Math.cos(ang - 0.4), p2.y - head * Math.sin(ang - 0.4));
        cx.lineTo(p2.x - head * Math.cos(ang + 0.4), p2.y - head * Math.sin(ang + 0.4));
        cx.closePath(); cx.fill();
      }
    }
    // Anything that carries words gets them drawn, or a flattened markup would
    // be a box with the comment missing — the one thing the recipient needed.
    if (a.text && ['Text Comment', 'Sticky Note', 'Callout', 'Text Box', 'Stamp', 'Measurement'].includes(a.type)) {
      cx.globalAlpha = 1;
      const size = Math.max(9, W / 78);
      const boxW = Math.max(px(rc.w), size * 8);
      cx.fillStyle = a.type === 'Stamp' ? 'rgba(255,255,255,0.9)' : 'rgba(255,250,225,0.95)';
      cx.fillRect(px(rc.x), py(rc.y), boxW, size * 1.8);
      cx.strokeStyle = a.type === 'Stamp' ? '#b82626' : (a.color || '#c9a227');
      cx.strokeRect(px(rc.x), py(rc.y), boxW, size * 1.8);
      cx.fillStyle = a.type === 'Stamp' ? '#b82626' : '#161311';
      cx.font = `${a.type === 'Stamp' ? 'bold ' : ''}${size}px Helvetica, Arial, sans-serif`;
      cx.fillText(String(a.text).slice(0, 90), px(rc.x) + size * 0.5, py(rc.y) + size * 1.2);
    }
    cx.restore();
  });
  // Form values, burned in. A flattened form that lost what was typed into it
  // is not a flattened form.
  (body.fields || []).filter(f => f.page === pageIndex && f.value !== '' && f.value != null).forEach(f => {
    const rc = f.rect || { x: 0, y: 0, w: 0.2, h: 0.03 };
    const size = Math.max(9, py(rc.h) * 0.72);
    cx.save();
    cx.fillStyle = '#161311';
    cx.font = `${size}px Helvetica, Arial, sans-serif`;
    const v = f.type === 'Checkbox' ? (f.value ? '✓' : '') : String(f.value);
    cx.fillText(v.slice(0, 120), px(rc.x) + 2, py(rc.y + rc.h) - size * 0.25);
    cx.restore();
  });
  // Signatures. A typed or drawn signature that vanished on flatten would make
  // the exported copy look unsigned.
  (body.signatures || []).filter(s => s.page === pageIndex && s.signedDate).forEach(s => {
    const rc = s.rect || { x: 0, y: 0, w: 0.22, h: 0.06 };
    cx.save();
    cx.strokeStyle = '#8a6a44'; cx.lineWidth = 1;
    cx.strokeRect(px(rc.x), py(rc.y), px(rc.w), py(rc.h));
    cx.fillStyle = '#161311';
    const size = Math.max(9, py(rc.h) * 0.34);
    cx.font = `italic ${size * 1.3}px Georgia, serif`;
    cx.fillText(String(s.typedName || s.signerName || '').slice(0, 40), px(rc.x) + 6, py(rc.y) + py(rc.h) * 0.52);
    cx.font = `${size * 0.8}px Helvetica, Arial, sans-serif`;
    cx.fillStyle = '#6b6560';
    cx.fillText(`${s.signerTitle || s.role || ''} ${s.signedDate ? '· ' + fmtDate(s.signedDate) : ''}`.trim().slice(0, 60),
                px(rc.x) + 6, py(rc.y) + py(rc.h) * 0.86);
    cx.restore();
  });
}

function OfficePdfToolExport({ ctx, doc, onChange, editable }) {
  const body = (doc && doc.body) || {};
  const plan = useMemo(() => officePdfToolPlan(body), [body]);
  const tokens = officePdfToolTokens(ctx, doc);
  const [result, setResult] = useState(null);
  const [flat, setFlat] = useState(null);
  const [imgFormat, setImgFormat] = useState('png');
  const [imgDpi, setImgDpi] = useState(150);
  const [imgPages, setImgPages] = useState('all');
  const [applyFinishing, setApplyFinishing] = useState(true);
  const { job, run, retry, clear } = useOfficePdfToolJob();

  const blockedByPolicy = (body.security || {}).restrictPrinting && !editable;

  function assembleWithFinishing(onProgress) {
    return officePdfLibs().then(({ PDFLib }) =>
      officePdfAssemble(plan, { onProgress, title: doc.name, author: ctx.currentUserName || 'LEON Integra',
                                subject: body.category || '' })
        .then(out => applyFinishing
          ? officePdfToolDrawFinishing(out, PDFLib, {
              tokens, headerFooter: body.headerFooter, watermark: body.watermark,
              pageLabels: body.pageLabels || {},
              stamps: (body.annotations || []).filter(a => a.type === 'Stamp')
                .map(a => ({ page: a.page, text: a.text || 'STAMP', position: 'Top right', size: 16,
                             sub: `${a.author || ''}${a.date ? ' · ' + fmtDate(a.date) : ''}` })),
            })
          : out)
        .then(out => officePdfSaveDoc(out)));
  }

  function exportPdfFile() {
    setResult(null);
    run('export', 'Writing the PDF', onProgress =>
      assembleWithFinishing(onProgress)
        .then(bytes => officePdfToolSaveResult(bytes,
          `${officePdfToolSafeName(doc.name, 'document')}.pdf`, { kind: 'pdf', from: 'export' }))
        .then(r => {
          setResult(r);
          officePdfDownload(r.bytes, r.name);
          officePdfToolPatch(ctx, doc, onChange, { workingAssetId: r.assetId });
          officePdfToolLog(ctx, doc, `Exported ${r.pageCount} pages (${officePdfBytesLabel(r.size)}).`);
          return r;
        })
    );
  }

  function flatten() {
    setFlat(null);
    run('flatten', 'Burning in the markup', onProgress =>
      officePdfRasterize(plan, {
        dpi: 200, quality: 0.92, onProgress,
        paint: (cx, r, p, n) => { officePdfToolMaskCrop(cx, r, p); officePdfToolPaintLayer(cx, r, n, body); },
      })
        .then(d => officePdfSaveDoc(d))
        .then(bytes => officePdfToolSaveResult(bytes,
          `${officePdfToolSafeName(doc.name, 'document')} — flattened.pdf`, { kind: 'pdf', from: 'flatten' }))
        .then(r => {
          setFlat(r);
          officePdfDownload(r.bytes, r.name);
          officePdfToolLog(ctx, doc, `Exported a flattened copy — markup, form values and signatures burned in.`);
          return r;
        })
    );
  }

  function exportImages() {
    const idx = imgPages === 'all' ? plan.map((p, i) => i) : officePdfToolParseRanges(imgPages, plan.length);
    if (!idx.length) return;
    run('export-images', 'Rendering pages', onProgress =>
      idx.reduce((chain, i, n) => chain.then(() => {
        const p = plan[i];
        return officePdfRenderPage(p.assetId, p.index, { scale: Number(imgDpi) / 72, dpr: 1, rotate: p.rotate || 0 })
          .then(r => new Promise(resolve => {
            r.canvas.toBlob(blob => {
              const url = URL.createObjectURL(blob);
              const a = document.createElement('a');
              a.href = url;
              a.download = `${officePdfToolSafeName(doc.name, 'page')} — p${i + 1}.${imgFormat === 'png' ? 'png' : 'jpg'}`;
              document.body.appendChild(a); a.click(); a.remove();
              setTimeout(() => URL.revokeObjectURL(url), 4000);
              onProgress(n + 1, idx.length);
              // Browsers throttle a burst of downloads; spacing them keeps a
              // 20-page export from silently losing half its files.
              setTimeout(resolve, 220);
            }, imgFormat === 'png' ? 'image/png' : 'image/jpeg', 0.92);
          }));
      }), Promise.resolve()).then(() => {
        officePdfToolLog(ctx, doc, `Exported ${idx.length} page image${idx.length === 1 ? '' : 's'} as ${imgFormat.toUpperCase()}.`);
        return idx.length;
      })
    );
  }

  function printIt() {
    run('print', 'Preparing for the printer', onProgress =>
      assembleWithFinishing(onProgress).then(bytes => { officePdfPrint(bytes); return true; }));
  }

  return (
    <div className="space-y-3">
      <OfficePdfToolHeader tool="export" />
      {blockedByPolicy && (
        <OfficePdfToolNote tone="warn">
          This document's LEON policy restricts printing and export to people with edit rights. That is a Hub
          rule, not a lock on the file.
        </OfficePdfToolNote>
      )}

      <label className="flex items-center gap-2 text-xs">
        <input type="checkbox" checked={applyFinishing} onChange={e => setApplyFinishing(e.target.checked)} />
        Draw the saved watermark, header/footer, page labels and stamps onto the export
      </label>

      <div className="grid sm:grid-cols-2 gap-2">
        <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-1.5">
          <p className="text-sm font-bold">📄 The document</p>
          <p className="text-[11px] text-[var(--leon-black)]/55">
            The pages in their current order, as real text. Selectable, searchable, and small.
          </p>
          <Button size="sm" disabled={blockedByPolicy || !plan.length} onClick={exportPdfFile}>Export PDF</Button>
        </div>
        <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-1.5">
          <p className="text-sm font-bold">🔒 A flattened copy</p>
          <p className="text-[11px] text-[var(--leon-black)]/55">
            Markup, stamps, form values and signatures burned into the page so no viewer can move or delete them.
            Every page becomes an image; the editable LEON document is kept exactly as it is.
          </p>
          <Button size="sm" variant="outline" disabled={blockedByPolicy || !plan.length} onClick={flatten}>Export flattened</Button>
        </div>
        <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-1.5">
          <p className="text-sm font-bold">🖼 Page images</p>
          <div className="grid grid-cols-3 gap-1.5">
            <Field label="Format">
              <Select value={imgFormat} onChange={e => setImgFormat(e.target.value)} className="!py-1 !text-xs">
                <option value="png">PNG</option><option value="jpg">JPG</option>
              </Select>
            </Field>
            <Field label="DPI"><TextInput type="number" className="!py-1 !text-xs" value={imgDpi} onChange={e => setImgDpi(e.target.value)} /></Field>
            <Field label="Pages"><TextInput className="!py-1 !text-xs" value={imgPages} onChange={e => setImgPages(e.target.value)} /></Field>
          </div>
          <Button size="sm" variant="outline" disabled={blockedByPolicy || !plan.length} onClick={exportImages}>Export images</Button>
        </div>
        <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-1.5">
          <p className="text-sm font-bold">🖨 Print</p>
          <p className="text-[11px] text-[var(--leon-black)]/55">
            Opens the browser's own print dialog against the assembled file. A page cannot drive a printer itself.
          </p>
          <Button size="sm" variant="outline" disabled={blockedByPolicy || !plan.length} onClick={printIt}>Print</Button>
        </div>
      </div>

      <OfficePdfToolJobBar job={job} onRetry={retry} onClear={clear} />
      <OfficePdfToolResult result={result} />
      <OfficePdfToolResult result={flat} />
      <OfficePdfToolNote>
        Every export writes a new file into the document store. Nothing above replaces the original, and a
        flattened copy never replaces the editable one — that is the copy you come back to when the same set has
        to go out again without the internal markup.
      </OfficePdfToolNote>
    </div>
  );
}

// ═════════════════════════════════════════════ LEON Office → PDF (text only)
//
// Publishing a LEON Word / Sheets / Presentation document to PDF here writes
// its TEXT: headings, paragraphs, lists, table rows, sheet rows, slide text.
// It does NOT redraw the document's layout, images, charts or live data blocks
// — that renderer belongs to LEON Word and LEON Presentation, and a second
// implementation of it here would produce a document that looked subtly
// different from the one on screen, which is worse than not offering it. Where
// something is not drawn, a marker is printed in its place so the reader can
// see what is missing rather than assume the page was empty.
function officePdfToolStrip(html) {
  if (typeof officeWordPlain === 'function') return officeWordPlain(html);
  const box = document.createElement('div');
  box.innerHTML = String(html == null ? '' : html).replace(/<br\s*\/?>/gi, ' ');
  return (box.textContent || '').replace(/\s+/g, ' ').trim();
}
function officePdfToolCell(c) {
  if (c == null) return '';
  if (typeof c === 'object') return officePdfToolStrip(c.html != null ? c.html : (c.text != null ? c.text : c.v));
  return String(c);
}
const PDFTOOL_LINES_PER_PAGE = 34;

function officePdfToolChunk(name, lines, perPage) {
  const out = [];
  const n = perPage || PDFTOOL_LINES_PER_PAGE;
  for (let i = 0; i < lines.length; i += n) {
    out.push({ eyebrow: out.length ? 'continued' : '', title: out.length ? name + ' (cont.)' : name,
               lines: lines.slice(i, i + n), footer: name });
  }
  return out.length ? out : [{ title: name, lines: ['This document is empty.'], footer: name }];
}

function officePdfToolOfficeSpecs(d) {
  const body = d.body || {};
  if (d.app === 'sheet') {
    const specs = [];
    (body.sheets || []).forEach(sh => {
      const cells = sh.cells || {};
      let maxR = 0, maxC = 0;
      Object.keys(cells).forEach(k => {
        const [r, c] = k.split(',').map(Number);
        if (r > maxR) maxR = r;
        if (c > maxC) maxC = c;
      });
      const lines = [];
      for (let r = 0; r <= maxR && r < 400; r++) {
        const row = [];
        for (let c = 0; c <= maxC && c < 16; c++) row.push(officePdfToolCell(cells[`${r},${c}`]));
        if (row.some(Boolean)) lines.push(row.join('  |  '));
      }
      officePdfToolChunk(`${d.name} — ${sh.name}`, lines).forEach(s => specs.push(s));
    });
    return specs.length ? specs : [{ title: d.name, lines: ['This spreadsheet has no cells yet.'] }];
  }
  if (d.app === 'slides') {
    const specs = (body.slides || []).map((sl, i) => {
      const lines = [];
      (sl.elements || []).forEach(el => {
        const t = officePdfToolStrip(el.html != null ? el.html : el.text);
        if (t) lines.push(t);
        else if (el.type) lines.push(`[${el.type}]`);
      });
      return { eyebrow: `Slide ${i + 1} of ${(body.slides || []).length}`,
               title: officePdfToolStrip(sl.title) || `Slide ${i + 1}`,
               lines: lines.length ? lines : ['(no text on this slide)'], footer: d.name };
    });
    return specs.length ? specs : [{ title: d.name, lines: ['This presentation has no slides yet.'] }];
  }
  // Word
  const lines = [];
  let numbered = 0;
  (body.blocks || []).forEach(b => {
    const t = officePdfToolStrip(b.html != null ? b.html : b.text);
    if (b.type === 'heading') { lines.push(''); lines.push((t || '').toUpperCase()); numbered = 0; }
    else if (b.type === 'bullet') lines.push('· ' + t);
    else if (b.type === 'number') { numbered++; lines.push(numbered + '. ' + t); }
    else if (b.type === 'quote') lines.push('"' + t + '"');
    else if (b.type === 'divider') lines.push('------------------------------');
    else if (b.type === 'pagebreak') { while (lines.length % PDFTOOL_LINES_PER_PAGE !== 0) lines.push(''); }
    else if (b.type === 'table') {
      (b.rows || []).forEach(row => lines.push((row || []).map(officePdfToolCell).join('  |  ')));
    } else if (b.type === 'image') lines.push(`[image${b.caption ? ': ' + b.caption : ''} — not drawn here; see the document in LEON Word]`);
    else if (b.type === 'data') lines.push(`[LEON data table${b.caption ? ': ' + b.caption : ''} — not drawn here; see the document in LEON Word]`);
    else if (b.type === 'toc') lines.push('[table of contents]');
    else { lines.push(t); numbered = 0; }
  });
  return officePdfToolChunk(d.name, lines.filter((l, i) => l !== '' || i === 0 || lines[i - 1] !== ''));
}

// Everything filed against a job that might be worth putting in a PDF. Grouped
// the way somebody thinks about a project, not the way the data is stored.
function officePdfToolProjectAttachments(project) {
  const groups = [];
  const add = (name, items) => { if (items.length) groups.push({ name, items }); };
  add('Project documents', (project.documents || []).map(d => officePdfToolItem({
    name: d.name, sub: d.type || '', fileUrl: d.fileUrl || null, ref: { kind: 'projectDocument', documentId: d.id } })));
  add('Drawing sets', (project.drawingSets || []).filter(d => d.status !== 'Void').map(d => officePdfToolItem({
    name: d.name, sub: `Rev ${d.revision || '—'}`, fileUrl: d.fileUrl || null, ref: { kind: 'drawingSet', drawingSetId: d.id } })));
  (project.scopes || []).forEach(s => {
    const items = [];
    (s.documents || []).forEach(d => items.push(officePdfToolItem({
      name: d.name, sub: d.type || '', fileUrl: d.fileUrl || null,
      ref: { kind: 'scopeDocument', scopeId: s.id, documentId: d.id } })));
    (s.submittals || []).forEach(t => (t.revisions || []).forEach(r => items.push(officePdfToolItem({
      name: r.file || `${t.name} Rev. ${r.revisionNumber}`, sub: `${t.docType || 'Submittal'} · ${r.status || ''}`,
      fileUrl: r.fileUrl || null, ref: { kind: 'submittal', scopeId: s.id, submittalId: t.id, revisionId: r.id } }))));
    add(s.name, items);
  });
  const proc = [];
  (project.vendorEstimates || []).forEach(ve => (ve.revisions || []).forEach(r => {
    if (!r.fileUrl && !r.file) return;
    proc.push(officePdfToolItem({ name: r.file || `${ve.estimateNumber} Rev ${r.revision}`, sub: ve.vendorName || '',
                                  fileUrl: r.fileUrl || null, ref: { kind: 'vendorEstimate', vendorEstimateId: ve.id } }));
  }));
  (project.proformaInvoices || []).forEach(pi => proc.push(officePdfToolItem({
    name: pi.file || pi.piNumber, sub: pi.vendorName || '', fileUrl: pi.fileUrl || null,
    ref: { kind: 'proformaInvoice', proformaInvoiceId: pi.id } })));
  (project.apInvoices || []).filter(apInvoiceLive).forEach(inv => proc.push(officePdfToolItem({
    name: inv.file || inv.invoiceNumber, sub: `${inv.partyType} · ${inv.vendorName || ''}`,
    fileUrl: inv.fileUrl || null, ref: { kind: 'apInvoice', apInvoiceId: inv.id } })));
  add('Procurement & invoices', proc);
  add('Closeout', ((project.closeout || {}).documents || []).map(d => officePdfToolItem({
    name: d.name || d.type, sub: d.type || '', fileUrl: d.fileUrl || null,
    ref: { kind: 'closeoutDocument', documentId: d.id } })));
  return groups;
}

// ═══════════════════════════════════════════════════════════ THE NEW → PDF FLOW
const PDFTOOL_CREATE_MODES = [
  { key: 'open', icon: '📂', label: 'Open a PDF', blurb: 'A file from this computer, stored exactly as it arrived.' },
  { key: 'images', icon: '🖼️', label: 'From images', blurb: 'Photographs or scans, one page each.' },
  { key: 'combine', icon: '🧩', label: 'Combine files', blurb: 'Several PDFs and images, in an order you choose.' },
  { key: 'office', icon: '📄', label: 'From a LEON document', blurb: 'Publish a Word, Sheets or Presentation document.' },
  { key: 'project', icon: '🏗️', label: 'From a project', blurb: 'Files already filed against a job.' },
];

function OfficePdfCreatePanel({ ctx, onCreated }) {
  const [mode, setMode] = useState('open');
  const [name, setName] = useState('');
  const [category, setCategory] = useState('Other');
  const [files, setFiles] = useState([]);
  const [officeDocId, setOfficeDocId] = useState('');
  const [projectId, setProjectId] = useState('');
  const [picked, setPicked] = useState([]);
  const [error, setError] = useState(null);
  const { job, run, retry, clear } = useOfficePdfToolJob();
  const input = useRef(null);

  const projects = ctx.deptProjects ? ctx.deptProjects(ctx.projects || []) : (ctx.projects || []);
  const project = projects.find(p => p.id === projectId);
  const officeCandidates = (ctx.officeDocs || []).filter(d => d.app !== 'pdf' && !d.archived);
  const groups = project ? officePdfToolProjectAttachments(project) : [];

  function finish(assetId, pageCount, sourceType, extra) {
    const pages = [];
    for (let i = 0; i < pageCount; i++) pages.push(makePdfPage(assetId, i, (extra && extra.refs) ? { sourceRef: extra.refs[i] || null } : {}));
    onCreated({
      ...makePdfBody(),
      originalAssetId: assetId, pageCount, pages,
      sourceType, category,
      ...(extra && extra.body ? extra.body : {}),
    }, {
      name: name || (extra && extra.name) || 'Untitled PDF',
      projectId: projectId || (extra && extra.projectId) || null,
    });
  }

  function openPdf(list) {
    if (!list || !list.length) return;
    setError(null);
    run('create', 'Reading the file', () => officePdfImportFile(list[0]).then(r => {
      finish(r.assetId, r.pageCount, 'Uploaded', { name: String(list[0].name).replace(/\.pdf$/i, '') });
      return r;
    }));
  }

  function fromImages(list) {
    if (!list || !list.length) return;
    setError(null);
    run('create', 'Converting images', onProgress =>
      officePdfFromImages(list, { onProgress })
        .then(d => officePdfSaveDoc(d))
        .then(bytes => officePdfToolSaveResult(bytes, (name || 'images') + '.pdf', { kind: 'pdf', from: 'images' }))
        .then(r => { finish(r.assetId, r.pageCount, 'Images', { name: name || 'Scanned images' }); return r; }));
  }

  function combine() {
    if (!files.length) return;
    setError(null);
    run('create', 'Combining files', onProgress =>
      files.reduce((chain, f, i) => chain.then(acc => {
        const isPdf = /pdf/i.test(f.type) || /\.pdf$/i.test(f.name);
        const p = isPdf
          ? officePdfImportFile(f)
          : officePdfFromImages([f], {}).then(d => officePdfSaveDoc(d))
              .then(b => officePdfToolSaveResult(b, f.name + '.pdf', { kind: 'pdf' }));
        return p.then(r => {
          for (let k = 0; k < r.pageCount; k++) {
            acc.push({ assetId: r.assetId, index: k,
                       sourceRef: { kind: isPdf ? 'pdf' : 'image', name: f.name, sourcePage: k + 1 } });
          }
          onProgress(i + 1, files.length);
          return acc;
        });
      }), Promise.resolve([]))
        .then(plan => officePdfAssemble(plan, { title: name || 'Combined' })
          .then(out => officePdfSaveDoc(out))
          .then(bytes => officePdfToolSaveResult(bytes, (name || 'combined') + '.pdf', { kind: 'pdf', from: 'combine' }))
          .then(r => {
            finish(r.assetId, r.pageCount, 'Combined',
                   { name: name || 'Combined document', refs: plan.map(p => p.sourceRef) });
            return r;
          })));
  }

  function fromOffice() {
    const d = (ctx.officeDocs || []).find(x => x.id === officeDocId);
    if (!d) return;
    setError(null);
    run('create', 'Writing the document', () =>
      officePdfToolMakeTextPages(officePdfToolOfficeSpecs(d), { pageSize: 'Letter' })
        .then(out => officePdfSaveDoc(out))
        .then(bytes => officePdfToolSaveResult(bytes, officePdfToolSafeName(d.name) + '.pdf', { kind: 'pdf', from: 'office' }))
        .then(r => {
          finish(r.assetId, r.pageCount,
                 d.app === 'sheet' ? 'LEON Sheets' : d.app === 'slides' ? 'LEON Presentation' : 'LEON Word',
                 { name: name || d.name, projectId: d.projectId || null,
                   body: { sourceDocId: d.id, sourceRevision: d.revision || 0 } });
          return r;
        }));
  }

  function fromProject() {
    const chosen = [];
    groups.forEach(g => (g.items || []).forEach(it => { if (picked.indexOf(it.id) >= 0) chosen.push({ ...it, group: g.name }); }));
    if (!chosen.length) return;
    setError(null);
    run('create', 'Collecting the files', onProgress =>
      chosen.reduce((chain, it, i) => chain.then(acc =>
        officePdfToolResolveAttachment(it).then(r => {
          if (r) {
            for (let k = 0; k < r.pageCount; k++) {
              acc.plan.push({ assetId: r.assetId, index: k,
                              sourceRef: { ...(it.ref || {}), name: it.name, sourcePage: k + 1 } });
            }
          } else acc.skipped.push(it.name);
          onProgress(i + 1, chosen.length);
          return acc;
        })
      ), Promise.resolve({ plan: [], skipped: [] }))
        .then(acc => {
          if (!acc.plan.length) {
            setError('None of the files chosen have bytes stored in the Hub — they are logged by name only, so there is nothing to merge.');
            throw new Error('Nothing to merge.');
          }
          if (acc.skipped.length) setError(`${acc.skipped.length} file${acc.skipped.length === 1 ? ' was' : 's were'} logged by name only and could not be merged: ${acc.skipped.slice(0, 4).join(', ')}${acc.skipped.length > 4 ? '…' : ''}`);
          return officePdfAssemble(acc.plan, { title: name || (project ? project.name : 'Project files') })
            .then(out => officePdfSaveDoc(out))
            .then(bytes => officePdfToolSaveResult(bytes,
              `${officePdfToolSafeName(name || (project ? project.name : 'project'))}.pdf`, { kind: 'pdf', from: 'project' }))
            .then(r => {
              finish(r.assetId, r.pageCount, 'Combined',
                     { name: name || `${project ? project.name : 'Project'} — documents`,
                       projectId, refs: acc.plan.map(p => p.sourceRef) });
              return r;
            });
        }));
  }

  return (
    <div className="space-y-3">
      <div className="grid sm:grid-cols-5 gap-1.5">
        {PDFTOOL_CREATE_MODES.map(m => (
          <button key={m.key} type="button" onClick={() => { setMode(m.key); setError(null); }}
            className={`text-left rounded-lg border px-3 py-2.5 ${
              mode === m.key ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)] bg-white'}`}>
            <span className="block text-sm font-semibold"><span aria-hidden="true" className="mr-1">{m.icon}</span>{m.label}</span>
            <span className="block text-[11px] text-[var(--leon-black)]/55 mt-0.5 leading-snug">{m.blurb}</span>
          </button>
        ))}
      </div>

      <div className="grid sm:grid-cols-3 gap-2">
        <Field label="Name" className="sm:col-span-2">
          <TextInput value={name} onChange={e => setName(e.target.value)} placeholder="Leave blank to use the file's own name" />
        </Field>
        <Field label="Category">
          <Select value={category} onChange={e => setCategory(e.target.value)}>
            {PDF_CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
          </Select>
        </Field>
      </div>

      {mode === 'open' && (
        <div className="flex items-center gap-2">
          <Button size="sm" onClick={() => input.current && input.current.click()}>Choose a PDF</Button>
          <input ref={input} type="file" accept="application/pdf,.pdf" className="hidden"
            onChange={e => { openPdf(e.target.files); e.target.value = ''; }} />
          <span className="text-[11px] text-[var(--leon-black)]/50">
            The bytes are stored exactly as they arrive and are never overwritten.
          </span>
        </div>
      )}

      {mode === 'images' && (
        <div className="flex items-center gap-2">
          <Button size="sm" onClick={() => input.current && input.current.click()}>Choose images</Button>
          <input ref={input} type="file" accept="image/png,image/jpeg" multiple className="hidden"
            onChange={e => { fromImages(e.target.files); e.target.value = ''; }} />
          <span className="text-[11px] text-[var(--leon-black)]/50">
            Each image gets a page its own size, so a set of photographs is not letterboxed onto Letter paper.
          </span>
        </div>
      )}

      {mode === 'combine' && (
        <div className="space-y-2">
          <div className="flex items-center gap-2">
            <Button size="sm" variant="outline" onClick={() => input.current && input.current.click()}>+ Add files</Button>
            <input ref={input} type="file" accept="application/pdf,.pdf,image/png,image/jpeg" multiple className="hidden"
              onChange={e => { setFiles(prev => prev.concat(Array.from(e.target.files))); e.target.value = ''; }} />
            <span className="text-xs text-[var(--leon-black)]/55">{files.length} file{files.length === 1 ? '' : 's'}</span>
          </div>
          <ul className="space-y-1">
            {files.map((f, i) => (
              <li key={i} className="flex items-center gap-2 text-xs rounded border border-[var(--leon-line)] px-2 py-1.5 bg-white">
                <span className="font-mono text-[var(--leon-black)]/40 w-5">{i + 1}</span>
                <span className="flex-1 truncate">{f.name}</span>
                <IconAction icon="↑" title="Move up" disabled={i === 0}
                  onClick={() => setFiles(prev => { const n = prev.slice(); n.splice(i - 1, 0, n.splice(i, 1)[0]); return n; })} />
                <IconAction icon="↓" title="Move down" disabled={i === files.length - 1}
                  onClick={() => setFiles(prev => { const n = prev.slice(); n.splice(i + 1, 0, n.splice(i, 1)[0]); return n; })} />
                <IconAction icon="✕" title="Remove" onClick={() => setFiles(prev => prev.filter((x, k) => k !== i))} />
              </li>
            ))}
          </ul>
          <Button size="sm" disabled={!files.length} onClick={combine}>Combine into one PDF</Button>
        </div>
      )}

      {mode === 'office' && (
        <div className="space-y-2">
          <Field label="LEON document">
            <Select value={officeDocId} onChange={e => setOfficeDocId(e.target.value)}>
              <option value="">Choose…</option>
              {officeCandidates.map(d => (
                <option key={d.id} value={d.id}>
                  {(OFFICE_APPS.find(a => a.key === d.app) || {}).label || d.app} — {d.name}
                </option>
              ))}
            </Select>
          </Field>
          <Button size="sm" disabled={!officeDocId} onClick={fromOffice}>Publish to PDF</Button>
          <OfficePdfToolNote tone="warn">
            This writes the document's <b>text</b> — headings, paragraphs, lists, table rows, sheet rows and slide
            text — at Letter size. It does not redraw the layout, images, charts or live data blocks: that
            renderer belongs to LEON Word and LEON Presentation, and a second one here would quietly produce a
            different-looking document. Anything not drawn is marked in place so you can see it is missing.
          </OfficePdfToolNote>
        </div>
      )}

      {mode === 'project' && (
        <div className="space-y-2">
          <Field label="Project">
            <Select value={projectId} onChange={e => { setProjectId(e.target.value); setPicked([]); }}>
              <option value="">Choose a project…</option>
              {projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
            </Select>
          </Field>
          {project && !groups.length && <EmptyState text="Nothing is filed against this job yet." />}
          {groups.map(g => (
            <Collapsible key={g.name} title={g.name} id={`pdftool-create-${g.name}`} count={g.items.length}>
              <ul className="space-y-1">
                {g.items.map(it => {
                  const mergeable = !!(it.fileUrl && /^data:/i.test(it.fileUrl));
                  return (
                    <li key={it.id} className="flex items-center gap-2 text-xs">
                      <input type="checkbox" disabled={!mergeable} checked={picked.indexOf(it.id) >= 0}
                        onChange={e => setPicked(prev => e.target.checked ? prev.concat([it.id]) : prev.filter(x => x !== it.id))} />
                      <span className={`flex-1 min-w-0 truncate ${mergeable ? '' : 'text-[var(--leon-black)]/35'}`}>
                        {it.name}{it.sub ? <span className="text-[var(--leon-black)]/45"> — {it.sub}</span> : null}
                      </span>
                      {!mergeable && <span className="text-[10px] text-[var(--leon-black)]/40 whitespace-nowrap">logged by name only</span>}
                    </li>
                  );
                })}
              </ul>
            </Collapsible>
          ))}
          <Button size="sm" disabled={!picked.length} onClick={fromProject}>
            Build a PDF from {picked.length} file{picked.length === 1 ? '' : 's'}
          </Button>
          <p className="text-[11px] text-[var(--leon-black)]/45">
            Only a record with an uploaded file has bytes to merge. A drawing set or a PO logged by name is
            greyed out rather than hidden, so it is obvious what is not in the result.
          </p>
        </div>
      )}

      {error && <OfficePdfToolNote tone="warn">{error}</OfficePdfToolNote>}
      <OfficePdfToolJobBar job={job} onRetry={retry} onClear={clear} />
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════ THE PANEL
// One entry point for the ten tools. The viewer owns the document; this decides
// which tool is on screen and what it is allowed to change.
function OfficePdfToolsPanel({ ctx, doc, onChange, tool, onClose }) {
  if (!doc) return <EmptyState text="Open a PDF to use the tools." />;
  // Register the writer up front, so an action whose FIRST write is an audit
  // line still goes through the document shell rather than round it.
  if (doc.id && typeof onChange === 'function') {
    const c = officePdfToolLatest[doc.id];
    if (c) c.onChange = onChange;
    else officePdfToolLatest[doc.id] = { doc, at: 0, onChange };
  }
  const body = doc.body || {};
  const sec = body.security || {};
  // Edit rights come from the LEON Softwares module, exactly like every other
  // software in this folder. A locked document (a final signature has landed)
  // is read-only to everyone until it is forked — that is the point of locking.
  const canEditModuleHere = !!(ctx.canEdit && ctx.canEdit('softwares'));
  const editable = canEditModuleHere && !body.locked;

  const Tool = {
    organize: OfficePdfToolSplit,
    combine: OfficePdfToolCombine,
    package: OfficePdfToolPackage,
    brand: OfficePdfToolBrand,
    redact: OfficePdfToolRedact,
    compare: OfficePdfToolCompare,
    extract: OfficePdfToolExtract,
    optimize: OfficePdfToolOptimize,
    export: OfficePdfToolExport,
    protect: OfficePdfToolProtect,
  }[tool];

  return (
    <div className="space-y-3" data-print-region={`LEON PDF — ${(PDFTOOL_TOOLS[tool] || {}).label || 'Tools'}`}>
      {onClose && (
        <div className="flex items-center justify-end no-print">
          <IconAction icon="✕" title="Close the tool" onClick={onClose} />
        </div>
      )}

      {body.locked && (
        <OfficePdfToolNote tone="warn">
          This document is locked — a final signature has been recorded against it. Everything here can still be
          read and exported; nothing can be changed. Fork a revision to work on it further.
        </OfficePdfToolNote>
      )}
      {!canEditModuleHere && !body.locked && (
        <OfficePdfToolNote>
          You can read and export this document. Changing it needs edit rights on LEON Softwares.
        </OfficePdfToolNote>
      )}
      {sec.restrictEditing && canEditModuleHere && (
        <OfficePdfToolNote>
          Editing is restricted on this document by LEON policy — you have the rights, so you can still change it.
          The restriction has no effect on the exported file.
        </OfficePdfToolNote>
      )}

      {Tool
        ? <Tool ctx={ctx} doc={doc} onChange={onChange} editable={editable} />
        : <EmptyState text={`No tool called "${tool}".`} />}

      <p className="text-[11px] text-[var(--leon-black)]/40 leading-relaxed">
        Everything on this screen runs in this browser, on this computer — there is no PDF service behind it. A
        large document therefore depends on this machine, and every action taken here is written to the
        document's activity and, where it belongs to a job, to that job's change log.
      </p>
    </div>
  );
}
