// ═══════════════════════════════════════════════════════ LEON PDF — the editor
// The viewer, the page organiser, markup, forms and signatures. Everything that
// touches BYTES lives in office-pdf-core.jsx and is reached through the
// officePdf* functions there; nothing in this file ever calls pdfjsLib or
// PDFLib directly, and nothing here writes a PDF into localStorage.
//
// THE ONE IDEA THIS FILE IS BUILT ON. A page on screen is a POINTER
// (`body.pages[i]` = { assetId, index, rotate, crop }) at a page inside an
// immutable asset. Reordering, deleting, rotating, inserting and cropping edit
// that list of pointers and nothing else. That is why a 300-page reorder is
// instant, why it is undoable, and why the architect's issued drawing is still
// byte-for-byte the file they sent after an afternoon of markup. Bytes are only
// written when someone exports — which is the other agent's panel.
//
// WHAT IS HONESTLY NOT HERE, said once so it is not re-litigated per feature:
//   • There is no AI service and none can be connected from a page with no
//     backend, so every AI control is disabled rather than stubbed. The one
//     exception is the category suggestion, which is a keyword count over the
//     extracted text and is labelled as exactly that.
//   • No OCR engine is bundled. DETECTING a scan is real (a page with no text
//     objects is an image of words) and is done; RECOGNISING the words is not.
//   • pdf-lib cannot encrypt. "Restrict editing/printing" is LEON document
//     policy recorded on the record, not PDF security, and the panel says so.
//   • A typed name is not a digital signature. LEON Approval is an in-app
//     approval by an authenticated session; cryptographic signing needs a
//     certificate authority and a server, and neither exists here.

// ── Units and layout ──────────────────────────────────────────────────────
// A PDF point is 1/72", a CSS pixel is 1/96", so "actual size" is 4/3.
const PDFVIEW_CSS_PER_PT = 96 / 72;
const PDFVIEW_DEFAULT_PAGE = { width: 612, height: 792 };   // US Letter, until the real size is known
const PDFVIEW_GAP = 18;                                     // px between pages
const PDFVIEW_OVERSCAN = 2;                                 // rows drawn either side of the viewport
const PDFVIEW_MAX_DPR = 2;                                  // beyond this the memory cost buys nothing visible
const PDFVIEW_UNDO_LIMIT = 40;
const PDFVIEW_VERSION_LIMIT = 30;
const PDFVIEW_BODY_SNAPSHOTS = 8;                           // formal revisions that keep a full body copy

// The cache is bounded by BYTES, not by a count of pages. A count is the wrong
// unit: eight A0 drawings at 300% are two orders of magnitude more memory than
// eight A4 pages at 50%, and a count-based cache happily holds both.
const PDFVIEW_PAGE_CACHE_BYTES = 96 * 1024 * 1024;
const PDFVIEW_THUMB_CACHE_BYTES = 24 * 1024 * 1024;

const PDFVIEW_LAYOUTS = [
  { key: 'single', label: 'Single page', icon: '▭', perRow: 1, paged: true },
  { key: 'continuous', label: 'Continuous', icon: '▤', perRow: 1, paged: false },
  { key: 'two', label: 'Two pages', icon: '▭▭', perRow: 2, paged: true },
  { key: 'twoContinuous', label: 'Two-page continuous', icon: '▥', perRow: 2, paged: false },
];
const PDFVIEW_ZOOM_STEPS = [0.25, 0.35, 0.5, 0.67, 0.8, 1, 1.25, 1.5, 2, 3, 4, 6];

// ── Modes and the header toolbar ──────────────────────────────────────────
// §99–102. A mode does not take a capability away from someone who has it — it
// decides how much of the toolbar is on screen at once. Basic is what most
// people need to read and comment; Advanced is everything; Project is the
// LEON-linked half; AI is the panel that says why there is no AI.
const PDFVIEW_MODES = [
  { key: 'basic', label: 'Basic', hint: 'Read, comment, fill and sign.' },
  { key: 'advanced', label: 'Advanced', hint: 'Every markup, form and page tool.' },
  { key: 'project', label: 'Project', hint: 'What this document is joined to in the Hub.' },
  { key: 'ai', label: 'AI', hint: 'What an AI service would add, and why there is none.' },
];
const PDFVIEW_TOOLBAR = [
  { key: 'select', label: 'Select', icon: '➤', modes: ['basic', 'advanced', 'project'] },
  { key: 'edit', label: 'Edit', icon: '✎', modes: ['advanced'] },
  { key: 'comment', label: 'Comment', icon: '💬', modes: ['basic', 'advanced'] },
  { key: 'organize', label: 'Organize', icon: '🗂️', modes: ['advanced'] },
  { key: 'fillsign', label: 'Fill & Sign', icon: '✍️', modes: ['basic', 'advanced'] },
  { key: 'measure', label: 'Measure', icon: '📏', modes: ['advanced'] },
  { key: 'compare', label: 'Compare', icon: '⇄', modes: ['advanced', 'project'], tools: true },
  // These four are panels in office-pdf-tools.jsx. They were built but had no way
  // in from here — a tool nobody can reach is a tool that does not exist, so each
  // one gets its own button rather than hiding behind another tool's panel.
  { key: 'redact', label: 'Redact', icon: '⬛', modes: ['advanced'], tools: true },
  { key: 'brand', label: 'Brand & finish', icon: '🏷️', modes: ['advanced'], tools: true },
  { key: 'extract', label: 'Extract data', icon: '📤', modes: ['advanced', 'project'], tools: true },
  { key: 'optimize', label: 'Optimize', icon: '🗜️', modes: ['advanced'], tools: true },
  { key: 'protect', label: 'Protect', icon: '🔒', modes: ['advanced'], tools: true },
  { key: 'export', label: 'Export', icon: '⬇', modes: ['basic', 'advanced', 'project'], tools: true },
];

// ── The markup palette ────────────────────────────────────────────────────
// Every entry is one of PDF_ANNOTATION_TYPES (data.jsx). `shape` is how it is
// drawn, which is the only thing the canvas layer needs to know.
//   text   — snaps to real text rectangles
//   rect   — drag a box
//   line   — drag from A to B
//   path   — freehand, a list of points
//   poly   — click vertices, double-click to finish
//   point  — one click places it
const PDFVIEW_MARKUP = [
  { type: 'Highlight', icon: '🖍', shape: 'text', color: '#F5C518', opacity: 0.4, group: 'Text' },
  { type: 'Underline', icon: 'U̲', shape: 'text', color: '#1F7A3D', opacity: 1, group: 'Text' },
  { type: 'Strikeout', icon: 'S̶', shape: 'text', color: '#C0392B', opacity: 1, group: 'Text' },
  { type: 'Text Comment', icon: '💬', shape: 'point', color: '#8B5E34', opacity: 1, group: 'Notes' },
  { type: 'Sticky Note', icon: '📌', shape: 'point', color: '#F1B24A', opacity: 1, group: 'Notes' },
  { type: 'Callout', icon: '🗨', shape: 'rect', color: '#8B5E34', opacity: 1, group: 'Notes' },
  { type: 'Text Box', icon: '🅰️', shape: 'rect', color: '#161311', opacity: 1, group: 'Notes' },
  { type: 'Rectangle', icon: '▭', shape: 'rect', color: '#C0392B', opacity: 1, group: 'Shapes' },
  { type: 'Circle', icon: '◯', shape: 'rect', color: '#C0392B', opacity: 1, group: 'Shapes' },
  { type: 'Cloud', icon: '☁', shape: 'rect', color: '#C0392B', opacity: 1, group: 'Shapes' },
  { type: 'Line', icon: '╱', shape: 'line', color: '#C0392B', opacity: 1, group: 'Shapes' },
  { type: 'Arrow', icon: '↗', shape: 'line', color: '#C0392B', opacity: 1, group: 'Shapes' },
  { type: 'Polygon', icon: '⬠', shape: 'poly', color: '#C0392B', opacity: 1, group: 'Shapes', closed: true },
  { type: 'Polyline', icon: '〽', shape: 'poly', color: '#C0392B', opacity: 1, group: 'Shapes', closed: false },
  { type: 'Freehand', icon: '✏️', shape: 'path', color: '#C0392B', opacity: 1, group: 'Shapes' },
  { type: 'Stamp', icon: '🔖', shape: 'point', color: '#1F7A3D', opacity: 1, group: 'Stamps' },
];
const PDFVIEW_MARKUP_BY_TYPE = {};
PDFVIEW_MARKUP.forEach(m => { PDFVIEW_MARKUP_BY_TYPE[m.type] = m; });

const PDFVIEW_COLORS = ['#F5C518', '#C0392B', '#1F7A3D', '#2563EB', '#8B5E34', '#161311', '#7C3AED', '#EC4899'];
const PDFVIEW_WEIGHTS = [1, 2, 3, 5, 8];

// ── Measurement ───────────────────────────────────────────────────────────
// Basic measurement only, and the panel says where the real thing lives. A
// scaled construction take-off is Drawing Intelligence's job: it knows sheet
// scale, revisions and which quantities belong to which scope. Duplicating a
// second, unlinked quantity here is how two numbers for the same wall appear.
const PDFVIEW_MEASURE = [
  { key: 'distance', label: 'Distance', icon: '📏' },
  { key: 'perimeter', label: 'Perimeter', icon: '⬡' },
  { key: 'area', label: 'Area', icon: '⬟' },
];
const PDFVIEW_MEASURE_UNITS = ['mm', 'cm', 'm', 'in', 'ft'];

// ── LEON data → form field (§51) ──────────────────────────────────────────
// A field can be pointed at a live Hub value. It is READ on demand and stamped
// into the field's value when someone asks for it — the document keeps a copy
// of what the value was when it was filled, because a signed form must not
// silently change wording six months later.
const PDFVIEW_LEON_SOURCES = [
  { key: 'project.name', label: 'Project — name' },
  { key: 'project.number', label: 'Project — number' },
  { key: 'project.address', label: 'Project — address' },
  { key: 'project.status', label: 'Project — status' },
  { key: 'project.startDate', label: 'Project — start date' },
  { key: 'project.contractValue', label: 'Project — contract value' },
  { key: 'account.name', label: 'Client — name' },
  { key: 'account.contact', label: 'Client — primary contact' },
  { key: 'account.email', label: 'Client — email' },
  { key: 'account.phone', label: 'Client — phone' },
  { key: 'vendor.name', label: 'Vendor — name' },
  { key: 'vendor.contact', label: 'Vendor — primary contact' },
  { key: 'company.name', label: 'LEON — company name' },
  { key: 'company.address', label: 'LEON — address' },
  { key: 'company.phone', label: 'LEON — phone' },
  { key: 'doc.number', label: 'Document — LEON number' },
  { key: 'doc.revision', label: 'Document — revision' },
  { key: 'user.name', label: 'Signed-in user — name' },
  { key: 'today', label: "Today's date" },
];

// ── Category suggestion ───────────────────────────────────────────────────
// A word count, not a classifier. It is offered because it is genuinely useful
// on a folder of scanned vendor paperwork and it is deterministic, so the same
// document always gets the same suggestion. It is never applied on its own.
const PDFVIEW_CATEGORY_HINTS = {
  Contract: ['agreement', 'this contract', 'party of the first', 'hereinafter', 'terms and conditions', 'witnesseth'],
  Estimate: ['estimate', 'budgetary', 'rough order of magnitude'],
  Proposal: ['proposal', 'we are pleased to propose', 'scope of work', 'exclusions'],
  Submittal: ['submittal', 'for approval', 'approved as noted', 'revise and resubmit', 'shop drawing'],
  Specification: ['specification', 'section 0', 'division 0', 'part 1 - general', 'part 2 - products'],
  'Product Data': ['product data', 'technical data sheet', 'installation instructions'],
  Invoice: ['invoice', 'invoice no', 'amount due', 'remit to', 'net 30'],
  'Vendor Quote': ['quotation', 'quote no', 'valid for', 'lead time', 'fob'],
  'Packing List': ['packing list', 'carton', 'gross weight', 'net weight', 'cbm'],
  'Purchase Order': ['purchase order', 'p.o. number', 'po number', 'ship to'],
  Report: ['report', 'findings', 'summary of', 'observations'],
  Letter: ['dear ', 'sincerely', 'yours truly'],
  Manual: ['user manual', 'maintenance', 'care and cleaning', 'troubleshooting'],
  Warranty: ['warranty', 'warrants', 'limited warranty', 'defects in material'],
  Closeout: ['closeout', 'as-built', 'punch list', 'substantial completion'],
  Drawing: ['scale', 'sheet no', 'drawn by', 'elevation', 'section a-a', 'north'],
  Form: ['please print', 'signature', 'date of birth', 'check one', 'form no'],
};

// ── AI, named honestly ────────────────────────────────────────────────────
const PDFVIEW_AI_OPS = [
  { key: 'ask', label: 'Ask this PDF a question', would: 'answer a question using the words in this document.' },
  { key: 'summarize', label: 'Summarize', would: 'write a summary of what this document says.' },
  { key: 'classify', label: 'Classify by AI', would: 'read the document and choose its category.' },
  { key: 'changes', label: 'Summarize the changes', would: 'describe in words what changed against the previous revision.' },
  { key: 'extract', label: 'Extract the data', would: 'pull line items and totals out of a quote or invoice.' },
];

// ═══════════════════════════════════════════════════ small shared utilities

function officePdfViewNowTime() {
  const d = new Date();
  return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
}
function officePdfViewClamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function officePdfViewPct(v) { return officePdfViewClamp(v, 0, 1); }

// A bounded, byte-budgeted LRU. Values are canvases; `bytes` is the honest cost
// of the bitmap (w × h × 4), which is what actually sits in memory.
function officePdfViewMakeLru(budget) {
  const map = new Map();          // insertion order IS the recency order
  let used = 0;
  return {
    get(key) {
      if (!map.has(key)) return null;
      const v = map.get(key);
      map.delete(key); map.set(key, v);   // re-insert = touch
      return v.value;
    },
    has(key) { return map.has(key); },
    set(key, value, bytes) {
      if (map.has(key)) { used -= map.get(key).bytes; map.delete(key); }
      map.set(key, { value, bytes });
      used += bytes;
      while (used > budget && map.size > 1) {
        const oldest = map.keys().next().value;
        used -= map.get(oldest).bytes;
        map.delete(oldest);
      }
    },
    clear() { map.clear(); used = 0; },
    get size() { return map.size; },
    get bytes() { return used; },
  };
}
const officePdfViewPageCache = officePdfViewMakeLru(PDFVIEW_PAGE_CACHE_BYTES);
const officePdfViewThumbCache = officePdfViewMakeLru(PDFVIEW_THUMB_CACHE_BYTES);
// In-flight renders, so two components asking for the same page at the same
// width start ONE pdf.js render task instead of two.
const officePdfViewPending = new Map();

function officePdfViewRenderKey(assetId, index, rotate, width) {
  // Width is bucketed to 40px. Without a bucket every pixel of a window resize
  // is a cache miss and a full re-render of every visible page.
  return assetId + ':' + index + ':' + ((rotate || 0) % 360) + ':' + Math.round(width / 40);
}
function officePdfViewRender(cache, assetId, index, rotate, width) {
  const key = officePdfViewRenderKey(assetId, index, rotate, width);
  const hit = cache.get(key);
  if (hit) return Promise.resolve(hit);
  if (officePdfViewPending.has(key)) return officePdfViewPending.get(key);
  const dpr = Math.min(window.devicePixelRatio || 1, PDFVIEW_MAX_DPR);
  const p = officePdfRenderPage(assetId, index, { width, rotate: rotate || 0, dpr })
    .then(res => {
      cache.set(key, res, res.canvas.width * res.canvas.height * 4);
      officePdfViewPending.delete(key);
      return res;
    })
    .catch(err => { officePdfViewPending.delete(key); throw err; });
  officePdfViewPending.set(key, p);
  return p;
}

// Natural page sizes, memoised for the session. Asset bytes are immutable, so a
// size can never go stale.
const officePdfViewSizes = new Map();
function officePdfViewSize(assetId, index) {
  const key = assetId + ':' + index;
  if (officePdfViewSizes.has(key)) return officePdfViewSizes.get(key);
  const p = officePdfPageSize(assetId, index).catch(() => ({ ...PDFVIEW_DEFAULT_PAGE, rotation: 0 }));
  officePdfViewSizes.set(key, p);
  return p;
}

// Extracted text per ASSET, not per document — two documents that both include
// the vendor's quote share one index instead of extracting it twice.
const officePdfViewTextCache = new Map();
function officePdfViewAssetText(assetId, onProgress) {
  if (officePdfViewTextCache.has(assetId)) return officePdfViewTextCache.get(assetId);
  const p = officePdfGetText(assetId).then(saved => {
    if (saved && saved.length) return saved;
    return officePdfExtractText(assetId, onProgress).then(pages =>
      officePdfPutText(assetId, pages).then(() => pages));
  });
  officePdfViewTextCache.set(assetId, p);
  p.catch(() => officePdfViewTextCache.delete(assetId));
  return p;
}

// A percentage rectangle recorded against an UNROTATED page, re-expressed for a
// page the LEON layer has turned. Text rects come out of pdf.js unrotated; a
// markup snapped to them has to follow the page when it is turned 90°.
function officePdfViewRotRect(r, rot) {
  const d = ((rot || 0) % 360 + 360) % 360;
  if (d === 90) return { x: 1 - r.y - r.h, y: r.x, w: r.h, h: r.w };
  if (d === 180) return { x: 1 - r.x - r.w, y: 1 - r.y - r.h, w: r.w, h: r.h };
  if (d === 270) return { x: r.y, y: 1 - r.x - r.w, w: r.h, h: r.w };
  return { x: r.x, y: r.y, w: r.w, h: r.h };
}
function officePdfViewNormRect(a, b) {
  return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(a.x - b.x), h: Math.abs(a.y - b.y) };
}
function officePdfViewPointsBounds(pts) {
  if (!pts || !pts.length) return { x: 0, y: 0, w: 0, h: 0 };
  let x0 = 1, y0 = 1, x1 = 0, y1 = 0;
  pts.forEach(p => { x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y); x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y); });
  return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}

// Keyword count over the extracted text. Deterministic and shallow, and the UI
// never calls it anything grander than a suggestion.
function officePdfViewSuggestCategory(pages) {
  const text = (pages || []).map(p => p.text || '').join('\n').toLowerCase().slice(0, 400000);
  if (!text.trim()) return null;
  let best = null;
  Object.keys(PDFVIEW_CATEGORY_HINTS).forEach(cat => {
    let score = 0;
    PDFVIEW_CATEGORY_HINTS[cat].forEach(word => {
      let at = 0, n = 0;
      while ((at = text.indexOf(word, at)) !== -1 && n < 40) { n++; at += word.length; }
      score += n * (word.length > 12 ? 3 : 1);
    });
    if (score > 0 && (!best || score > best.score)) best = { category: cat, score };
  });
  if (!best || best.score < 3) return null;
  return best;
}

// @mentions. Longest names first so "@Ana Maria Cruz" is not eaten by "@Ana".
function officePdfViewMentions(text, team) {
  const found = [];
  const t = text || '';
  [...(team || [])].sort((a, b) => (b.name || '').length - (a.name || '').length).forEach(p => {
    if (!p.name) return;
    if (t.toLowerCase().indexOf('@' + p.name.toLowerCase()) !== -1) found.push(p.id);
  });
  return [...new Set(found)];
}

// Reads a live Hub value for a form field (§51). Every path is resolved here so
// there is one place that knows what "project.address" means.
function officePdfViewResolveLeon(ctx, doc, path) {
  const project = doc.projectId ? (ctx.projects || []).find(p => p.id === doc.projectId) : null;
  const account = doc.accountId
    ? (ctx.accounts || []).find(a => a.id === doc.accountId)
    : (project ? (ctx.accounts || []).find(a => a.id === project.accountId) : null);
  const vendor = doc.vendorId ? (ctx.vendors || []).find(v => v.id === doc.vendorId) : null;
  const co = ctx.companyProfile || {};
  const contact = (a) => (a && (a.contactName || (a.contacts && a.contacts[0] && a.contacts[0].name))) || '';
  const map = {
    'project.name': project && project.name,
    'project.number': project && (project.number || project.projectNumber),
    'project.address': project && (project.address || project.siteAddress),
    'project.status': project && project.status,
    'project.startDate': project && project.startDate,
    'project.contractValue': project && project.contractValue != null
      ? (typeof fmtMoney === 'function' ? fmtMoney(project.contractValue) : String(project.contractValue)) : '',
    'account.name': account && account.name,
    'account.contact': contact(account),
    'account.email': account && (account.email || (account.contacts && account.contacts[0] && account.contacts[0].email)),
    'account.phone': account && (account.phone || (account.contacts && account.contacts[0] && account.contacts[0].phone)),
    'vendor.name': vendor && vendor.name,
    'vendor.contact': contact(vendor),
    'company.name': co.legalName || co.name,
    'company.address': [co.addressLine1, co.addressLine2].filter(Boolean).join(', '),
    'company.phone': co.phone,
    'doc.number': (doc.body && doc.body.documentNumber) || '',
    'doc.revision': doc.revision != null ? String(doc.revision) : '',
    'user.name': ctx.currentUserName,
    today: typeof todayISO === 'function' ? todayISO() : '',
  };
  const v = map[path];
  return v == null ? '' : String(v);
}

// The plan, filtered to what is actually shown. `hidden` exists so the tools
// agent can stand a page down without losing its markup.
function officePdfViewVisiblePages(body) {
  return (body.pages || []).map((p, i) => ({ ...p, planIndex: i })).filter(p => !p.hidden);
}

// ═══════════════════════════════════════════════════ the rendered page canvas
// Draws a cached bitmap into its OWN canvas rather than adopting the cached
// canvas node. A cached node can only live in one place in the DOM, and a
// duplicated page appears twice in the same document — adopting it would make
// the second copy steal it from the first and leave a hole.
function OfficePdfViewCanvas({ assetId, index, rotate, width, height, thumb, onDrawn }) {
  const ref = useRef(null);
  const [state, setState] = useState('idle');

  useEffect(() => {
    let live = true;
    if (!assetId || !(width > 0)) return undefined;
    setState('drawing');
    officePdfViewRender(thumb ? officePdfViewThumbCache : officePdfViewPageCache, assetId, index, rotate, width)
      .then(res => {
        if (!live || !ref.current) return;
        const c = ref.current;
        c.width = res.canvas.width; c.height = res.canvas.height;
        c.style.width = width + 'px';
        c.style.height = (height || (width * res.canvas.height / res.canvas.width)) + 'px';
        c.getContext('2d').drawImage(res.canvas, 0, 0);
        setState('done');
        if (onDrawn) onDrawn(res);
      })
      .catch(() => { if (live) setState('error'); });
    return () => { live = false; };
  }, [assetId, index, rotate, Math.round(width / 40), thumb]);

  return (
    <>
      <canvas ref={ref} style={{ width, height, display: 'block' }} />
      {state === 'error' && (
        <div className="absolute inset-0 flex items-center justify-center text-[11px] text-[var(--leon-red,#C0392B)] bg-white/85 px-2 text-center">
          This page could not be drawn.
        </div>
      )}
    </>
  );
}

// A page that is near the viewport but not drawn yet. It is sized to the real
// aspect ratio, which is the whole point: scrolling never jumps because a
// placeholder turned out to be the wrong height.
function OfficePdfViewPlaceholder({ width, height, label }) {
  return (
    <div style={{ width, height }}
      className="flex items-center justify-center bg-white border border-dashed border-[var(--leon-line)]">
      <span className="text-[11px] text-[var(--leon-black)]/30">{label}</span>
    </div>
  );
}

// ── The transparent text layer ────────────────────────────────────────────
// Real, selectable text sitting exactly over the picture of it. Two things
// depend on it: copying words out of a PDF, and turning a selection into a
// highlight that lands on the actual glyph boxes rather than a drawn rectangle.
// Each span carries its character offset, so a browser selection maps straight
// back to a character range and then through officePdfRectsFor.
function OfficePdfViewTextLayer({ pageText, rotate, width, height }) {
  if (!pageText || !pageText.items || !pageText.items.length) return null;
  // Rotation is applied to the boxes; the glyphs themselves stay upright,
  // which is fine because they are invisible — only their geometry is used.
  return (
    <div className="absolute inset-0" style={{ userSelect: 'text', cursor: 'text' }} data-pdf-textlayer="1">
      {pageText.items.map((it, i) => {
        if (!it.str) return null;
        const r = officePdfViewRotRect({ x: it.x, y: it.y, w: it.w, h: it.h }, rotate);
        return (
          <span key={i} data-start={it.start} data-len={it.str.length}
            style={{
              position: 'absolute', left: r.x * width, top: r.y * height,
              width: Math.max(1, r.w * width), height: Math.max(1, r.h * height),
              fontSize: Math.max(4, r.h * height * 0.92) + 'px', lineHeight: (r.h * height) + 'px',
              color: 'transparent', whiteSpace: 'pre', transformOrigin: 'left top',
              overflow: 'hidden',
            }}>
            {it.str}
          </span>
        );
      })}
    </div>
  );
}

// Reads the browser's selection back into a character range on one page. Returns
// null unless the whole selection sits inside a single page's text layer —
// a markup that silently spanned two pages would be recorded on the wrong one.
function officePdfViewSelectionRange() {
  const sel = window.getSelection && window.getSelection();
  if (!sel || sel.isCollapsed || !sel.rangeCount) return null;
  const spanOf = (node) => {
    let n = node;
    while (n && n.nodeType !== 1) n = n.parentNode;
    while (n && !(n.dataset && n.dataset.start !== undefined)) n = n.parentElement;
    return n;
  };
  const a = spanOf(sel.anchorNode), b = spanOf(sel.focusNode);
  if (!a || !b) return null;
  const layerA = a.closest('[data-pdf-page]'), layerB = b.closest('[data-pdf-page]');
  if (!layerA || !layerB || layerA !== layerB) return null;
  const sa = Number(a.dataset.start) + sel.anchorOffset;
  const sb = Number(b.dataset.start) + sel.focusOffset;
  const start = Math.min(sa, sb), end = Math.max(sa, sb);
  if (end <= start) return null;
  return { planIndex: Number(layerA.dataset.pdfPage), start, length: end - start, text: sel.toString() };
}

// ═══════════════════════════════════════════════════════ drawing the markups
// One SVG per page, in page-percentage coordinates scaled to the drawn size, so
// a markup is in the same place at 25% and at 400%.
function OfficePdfViewMarkupShape({ a, width, height, selected }) {
  const px = (v) => v * width, py = (v) => v * height;
  const r = a.rect || { x: 0, y: 0, w: 0, h: 0 };
  const stroke = a.color || '#C0392B';
  const w = a.weight || 2;
  const common = { stroke, strokeWidth: w, fill: 'none', opacity: a.opacity != null ? a.opacity : 1 };

  if (a.type === 'Highlight' || a.type === 'Underline' || a.type === 'Strikeout') {
    const rects = (a.rects && a.rects.length) ? a.rects : [r];
    return (
      <g>
        {rects.map((q, i) => {
          if (a.type === 'Highlight') {
            return <rect key={i} x={px(q.x)} y={py(q.y)} width={px(q.w)} height={py(q.h)}
              fill={stroke} opacity={a.opacity != null ? a.opacity : 0.4} />;
          }
          const y = a.type === 'Underline' ? py(q.y + q.h) - 1 : py(q.y + q.h * 0.55);
          return <line key={i} x1={px(q.x)} y1={y} x2={px(q.x + q.w)} y2={y} stroke={stroke} strokeWidth={Math.max(1.2, w)} />;
        })}
      </g>
    );
  }
  if (a.type === 'Rectangle') return <rect x={px(r.x)} y={py(r.y)} width={px(r.w)} height={py(r.h)} {...common} />;
  if (a.type === 'Circle') return <ellipse cx={px(r.x + r.w / 2)} cy={py(r.y + r.h / 2)} rx={px(r.w / 2)} ry={py(r.h / 2)} {...common} />;
  if (a.type === 'Cloud') {
    // A revision cloud is scallops round the box — drawn, not an image, so it
    // scales with the page like every other markup.
    const step = 14;
    const path = [];
    const bumps = (x0, y0, x1, y1) => {
      const dx = x1 - x0, dy = y1 - y0;
      const len = Math.sqrt(dx * dx + dy * dy) || 1;
      const n = Math.max(1, Math.round(len / step));
      for (let i = 0; i < n; i++) {
        const t0 = i / n, t1 = (i + 1) / n;
        const ax = x0 + dx * t0, ay = y0 + dy * t0;
        const bx = x0 + dx * t1, by = y0 + dy * t1;
        const mx = (ax + bx) / 2 - dy / len * step * 0.55;
        const my = (ay + by) / 2 + dx / len * step * 0.55;
        path.push(`Q ${mx} ${my} ${bx} ${by}`);
      }
    };
    const x0 = px(r.x), y0 = py(r.y), x1 = px(r.x + r.w), y1 = py(r.y + r.h);
    path.push(`M ${x0} ${y0}`); bumps(x0, y0, x1, y0); bumps(x1, y0, x1, y1);
    bumps(x1, y1, x0, y1); bumps(x0, y1, x0, y0);
    return <path d={path.join(' ')} {...common} />;
  }
  if (a.type === 'Line' || a.type === 'Arrow' || a.type === 'Measurement') {
    const p = a.points && a.points.length >= 2 ? a.points : [{ x: r.x, y: r.y }, { x: r.x + r.w, y: r.y + r.h }];
    const d = p.map((q, i) => (i ? 'L' : 'M') + ' ' + px(q.x) + ' ' + py(q.y)).join(' ');
    const closed = a.measureKind === 'area';
    return (
      <g>
        <path d={d + (closed ? ' Z' : '')} {...common}
          fill={closed ? stroke : 'none'} fillOpacity={closed ? 0.12 : 0} />
        {a.type === 'Arrow' && (() => {
          const s = p[p.length - 2], e = p[p.length - 1];
          const ang = Math.atan2(py(e.y) - py(s.y), px(e.x) - px(s.x));
          const L = 10 + w * 2;
          return <path d={`M ${px(e.x)} ${py(e.y)} L ${px(e.x) - L * Math.cos(ang - 0.4)} ${py(e.y) - L * Math.sin(ang - 0.4)}
                           M ${px(e.x)} ${py(e.y)} L ${px(e.x) - L * Math.cos(ang + 0.4)} ${py(e.y) - L * Math.sin(ang + 0.4)}`}
            stroke={stroke} strokeWidth={w} fill="none" />;
        })()}
      </g>
    );
  }
  if (a.type === 'Polygon' || a.type === 'Polyline') {
    const p = a.points || [];
    if (!p.length) return null;
    const d = p.map((q, i) => (i ? 'L' : 'M') + ' ' + px(q.x) + ' ' + py(q.y)).join(' ') + (a.type === 'Polygon' ? ' Z' : '');
    return <path d={d} {...common} />;
  }
  if (a.type === 'Freehand') {
    const p = a.points || [];
    if (!p.length) return null;
    const d = p.map((q, i) => (i ? 'L' : 'M') + ' ' + px(q.x) + ' ' + py(q.y)).join(' ');
    return <path d={d} {...common} strokeLinejoin="round" strokeLinecap="round" />;
  }
  if (a.type === 'Callout') {
    return (
      <g>
        <rect x={px(r.x)} y={py(r.y)} width={px(r.w)} height={py(r.h)} fill="#fff" stroke={stroke} strokeWidth={w} />
        {a.anchor && <line x1={px(r.x)} y1={py(r.y + r.h / 2)} x2={px(a.anchor.x)} y2={py(a.anchor.y)} stroke={stroke} strokeWidth={w} />}
      </g>
    );
  }
  if (a.type === 'Text Box') {
    return <rect x={px(r.x)} y={py(r.y)} width={px(r.w)} height={py(r.h)} fill="#fff" fillOpacity={0.9} stroke={stroke} strokeWidth={1} />;
  }
  return null;
}

// The parts of a markup that are TEXT, kept out of the SVG so they wrap, stay
// selectable and print as text rather than as paths.
function OfficePdfViewMarkupHtml({ a, width, height, selected, onOpen }) {
  const r = a.rect || { x: 0, y: 0, w: 0, h: 0 };
  const box = { position: 'absolute', left: r.x * width, top: r.y * height };
  if (a.type === 'Text Comment' || a.type === 'Sticky Note') {
    return (
      <button type="button" onClick={e => { e.stopPropagation(); onOpen && onOpen(a); }}
        title={a.text || a.type}
        style={{ ...box, transform: 'translate(-50%,-100%)' }}
        className={`pointer-events-auto text-lg leading-none drop-shadow ${selected ? 'ring-2 ring-[var(--leon-brown)] rounded' : ''}`}>
        {a.type === 'Sticky Note' ? '📌' : '💬'}
      </button>
    );
  }
  if (a.type === 'Stamp') {
    return (
      <div style={{ ...box, transform: 'translate(-50%,-50%) rotate(' + (a.rotation || 0) + 'deg)' }}
        className={`pointer-events-auto select-none ${selected ? 'ring-2 ring-[var(--leon-brown)]' : ''}`}>
        <div className="px-3 py-1.5 border-[3px] rounded font-extrabold tracking-wider uppercase whitespace-nowrap"
          style={{ borderColor: a.color || '#1F7A3D', color: a.color || '#1F7A3D',
                   fontSize: Math.max(9, height * 0.018), background: 'rgba(255,255,255,0.72)' }}>
          {a.text || 'STAMP'}
          {a.subtext && <div className="font-semibold tracking-normal normal-case" style={{ fontSize: '0.62em' }}>{a.subtext}</div>}
        </div>
      </div>
    );
  }
  if (a.type === 'Text Box' || a.type === 'Callout') {
    return (
      <div style={{ ...box, width: r.w * width, height: r.h * height, color: a.color || '#161311',
                    fontSize: Math.max(8, (a.fontSize || 0.014) * height), padding: 4, overflow: 'hidden' }}
        className="pointer-events-none whitespace-pre-wrap leading-snug">
        {a.text}
      </div>
    );
  }
  if (a.type === 'Measurement') {
    const b = officePdfViewPointsBounds(a.points || []);
    return (
      <div style={{ position: 'absolute', left: (b.x + b.w / 2) * width, top: (b.y + b.h / 2) * height,
                    transform: 'translate(-50%,-50%)' }}
        className="pointer-events-none px-1.5 py-0.5 rounded bg-white/90 border border-[var(--leon-line)] text-[10px] font-bold">
        <span style={{ color: a.color || '#C0392B' }}>{a.text}</span>
      </div>
    );
  }
  return null;
}

// ═══════════════════════════════════════════════════════════════ one page
// Everything that happens ON a page happens here: the bitmap, the text layer,
// the markups, the form fields, the signature blocks, the redaction marks and
// the pointer work that creates and edits them. Geometry in, geometry out —
// this component never touches the document record; it hands finished shapes
// up to the editor, which is the only thing that writes.
function OfficePdfViewPage({
  page, planIndex, width, height, render, pageNumber, label,
  annotations, fields, signatures, redactions, hits, flashHit,
  tool, toolOpts, selectedId, editable, pageText, showText,
  onSelect, onCreate, onUpdate, onOpenComment, onFieldChange, formMode, cropDraft, onCropDraft,
}) {
  const ref = useRef(null);
  const [draft, setDraft] = useState(null);        // the shape being drawn right now
  const [poly, setPoly] = useState(null);          // a polygon/polyline part-way through
  const dragRef = useRef(null);                    // moving or resizing an existing markup

  const markup = PDFVIEW_MARKUP_BY_TYPE[tool] || null;
  const measuring = tool && tool.indexOf('measure:') === 0 ? tool.slice(8) : null;
  const placingField = tool && tool.indexOf('field:') === 0 ? tool.slice(6) : null;
  const redacting = tool === 'redact';
  const cropping = tool === 'crop';
  const drawing = !!(markup || measuring || placingField || redacting || cropping);

  function pct(e) {
    const r = ref.current.getBoundingClientRect();
    return { x: officePdfViewPct((e.clientX - r.left) / r.width), y: officePdfViewPct((e.clientY - r.top) / r.height) };
  }

  // ── creating ────────────────────────────────────────────────────────────
  function down(e) {
    if (!editable) return;
    if (dragRef.current) return;
    if (!drawing) return;
    if (e.button !== 0) return;
    const p = pct(e);

    // A text markup prefers a REAL selection — exact glyph boxes and the quoted
    // words come free with it. A selection already standing when the tool was
    // picked is used immediately; otherwise the drag is tracked and pointerup
    // decides between whatever got selected and the box that was drawn. There
    // is deliberately no preventDefault on this path: stopping the default is
    // what would stop the browser selecting text in the first place.
    if (markup && markup.shape === 'text') {
      const sel = officePdfViewSelectionRange();
      if (sel && sel.planIndex === planIndex && pageText) {
        const rects = officePdfRectsFor(pageText, sel.start, sel.length).map(r => officePdfViewRotRect(r, page.rotate));
        if (rects.length) {
          onCreate({ type: tool, rect: officePdfViewPointsBounds(rects.map(r => ({ x: r.x, y: r.y }))),
                     rects, quote: sel.text, color: toolOpts.color, opacity: toolOpts.opacity, weight: toolOpts.weight });
          window.getSelection().removeAllRanges();
          return;
        }
      }
      setDraft({ start: p, cur: p, points: [p] });
      return;
    }
    // Perimeter and area are vertex-by-vertex like a polygon, not a drag: an
    // area is a shape someone picks the corners of, and dragging a lasso round
    // a room on a drawing produces a number nobody can check.
    const polyLike = (markup && markup.shape === 'poly')
      || (measuring && measuring !== 'distance' && measuring !== 'calibrate');
    if (polyLike) {
      e.preventDefault();
      setPoly(prev => (prev ? { ...prev, points: [...prev.points, p] } : { points: [p], cur: p }));
      return;
    }
    e.preventDefault();
    if (markup && markup.shape === 'point') {
      onCreate({ type: tool, rect: { x: p.x, y: p.y, w: 0, h: 0 }, color: toolOpts.color, opacity: toolOpts.opacity });
      return;
    }
    setDraft({ start: p, cur: p, points: [p] });
    try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) { /* older browsers */ }
  }
  function move(e) {
    if (poly) { setPoly(prev => prev && { ...prev, cur: pct(e) }); return; }
    if (dragRef.current) { dragMove(e); return; }
    if (!draft) return;
    const p = pct(e);
    setDraft(d => d && { ...d, cur: p, points: markup && markup.shape === 'path' ? [...d.points, p] : d.points });
  }
  function up(e) {
    if (dragRef.current) { dragEnd(); return; }
    if (!draft) return;
    const d = draft; setDraft(null);
    const rect = officePdfViewNormRect(d.start, d.cur);
    const tiny = rect.w < 0.004 && rect.h < 0.004;

    if (cropping) { if (!tiny) onCropDraft(rect); return; }
    if (redacting) { if (!tiny) onCreate({ __redaction: true, rect }); return; }
    if (placingField) { onCreate({ __field: placingField, rect: tiny ? { x: d.start.x, y: d.start.y, w: 0.18, h: 0.028 } : rect }); return; }
    if (measuring) { onCreate({ __measure: measuring, points: measuring === 'distance' ? [d.start, d.cur] : d.points }); return; }
    if (!markup) return;

    if (markup.shape === 'path') {
      if (d.points.length < 2) return;
      onCreate({ type: tool, points: d.points, rect: officePdfViewPointsBounds(d.points),
                 color: toolOpts.color, opacity: toolOpts.opacity, weight: toolOpts.weight });
      return;
    }
    if (markup.shape === 'line') {
      onCreate({ type: tool, points: [d.start, d.cur], rect,
                 color: toolOpts.color, opacity: toolOpts.opacity, weight: toolOpts.weight });
      return;
    }
    if (markup.shape === 'text') {
      // A selection made during this drag wins: it is character-exact.
      const sel2 = officePdfViewSelectionRange();
      if (sel2 && sel2.planIndex === planIndex && pageText) {
        const rects2 = officePdfRectsFor(pageText, sel2.start, sel2.length).map(q => officePdfViewRotRect(q, page.rotate));
        if (rects2.length) {
          onCreate({ type: tool, rect: officePdfViewPointsBounds(rects2.map(q => ({ x: q.x, y: q.y }))),
                     rects: rects2, quote: sel2.text, color: toolOpts.color, opacity: toolOpts.opacity, weight: toolOpts.weight });
          window.getSelection().removeAllRanges();
          return;
        }
      }
      if (tiny) return;
      // Otherwise: snap to whatever text items the dragged box actually covers,
      // so the highlight sits on the words rather than on the box someone drew.
      const rects = [];
      let quote = '';
      (pageText && pageText.items || []).forEach(it => {
        const r = officePdfViewRotRect({ x: it.x, y: it.y, w: it.w, h: it.h }, page.rotate);
        const overlap = !(r.x > rect.x + rect.w || r.x + r.w < rect.x || r.y > rect.y + rect.h || r.y + r.h < rect.y);
        if (overlap && it.str.trim()) { rects.push(r); quote += it.str; }
      });
      onCreate({ type: tool, rect, rects: rects.length ? rects : [rect], quote: quote.trim(),
                 color: toolOpts.color, opacity: toolOpts.opacity, weight: toolOpts.weight });
      return;
    }
    if (tiny) return;
    onCreate({ type: tool, rect, color: toolOpts.color, opacity: toolOpts.opacity, weight: toolOpts.weight,
               text: (tool === 'Text Box' || tool === 'Callout') ? '' : '' });
  }
  function finishPoly() {
    if (!poly || poly.points.length < 2) { setPoly(null); return; }
    if (measuring) { onCreate({ __measure: measuring, points: poly.points }); setPoly(null); return; }
    onCreate({ type: tool, points: poly.points, rect: officePdfViewPointsBounds(poly.points),
               color: toolOpts.color, opacity: toolOpts.opacity, weight: toolOpts.weight });
    setPoly(null);
  }

  // ── moving and resizing ─────────────────────────────────────────────────
  function dragStart(e, a, mode) {
    if (!editable) return;
    e.stopPropagation(); e.preventDefault();
    onSelect(a.id);
    dragRef.current = { a, mode, from: pct(e), rect: { ...(a.rect || {}) },
                        points: (a.points || []).map(p => ({ ...p })) };
    try { ref.current.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
  }
  function dragMove(e) {
    const d = dragRef.current;
    if (!d) return;
    const p = pct(e);
    const dx = p.x - d.from.x, dy = p.y - d.from.y;
    if (d.mode === 'move') {
      const next = { rect: { ...d.rect, x: officePdfViewPct(d.rect.x + dx), y: officePdfViewPct(d.rect.y + dy) } };
      if (d.points.length) next.points = d.points.map(q => ({ x: officePdfViewPct(q.x + dx), y: officePdfViewPct(q.y + dy) }));
      if (d.a.rects) next.rects = d.a.rects.map(q => ({ ...q, x: officePdfViewPct(q.x + dx), y: officePdfViewPct(q.y + dy) }));
      onUpdate(d.a.id, next, true);
    } else {
      onUpdate(d.a.id, { rect: { ...d.rect, w: Math.max(0.005, d.rect.w + dx), h: Math.max(0.005, d.rect.h + dy) } }, true);
    }
  }
  function dragEnd() {
    const d = dragRef.current;
    dragRef.current = null;
    if (d) onUpdate(d.a.id, {}, false);      // false = commit; the moves above were previews
  }

  const cursor = drawing ? 'crosshair' : (showText ? 'text' : 'default');
  const preview = draft ? officePdfViewNormRect(draft.start, draft.cur) : null;

  return (
    <div ref={ref} data-pdf-page={planIndex}
      className="relative bg-white shadow-sm border border-[var(--leon-line)] select-none"
      style={{ width, height, cursor }}
      onPointerDown={down} onPointerMove={move} onPointerUp={up}
      onDoubleClick={() => poly && finishPoly()}
      onClick={e => { if (!drawing && e.target === ref.current) onSelect(null); }}>

      {render
        ? <OfficePdfViewCanvas assetId={page.assetId} index={page.index} rotate={page.rotate} width={width} height={height} />
        : <OfficePdfViewPlaceholder width={width} height={height} label={'Page ' + pageNumber} />}

      {/* Search hits sit under the markup so a highlight never hides them. */}
      {!!(hits && hits.length) && (
        <svg className="absolute inset-0 pointer-events-none" width={width} height={height}>
          {hits.map((h, i) => (h.rects || []).map((r0, j) => {
            const r = officePdfViewRotRect(r0, page.rotate);
            return <rect key={i + '-' + j} x={r.x * width} y={r.y * height} width={r.w * width} height={r.h * height}
              fill={flashHit === h.key ? '#F5C518' : '#2563EB'} opacity={flashHit === h.key ? 0.65 : 0.22} />;
          }))}
        </svg>
      )}

      {render && showText && <OfficePdfViewTextLayer pageText={pageText} rotate={page.rotate} width={width} height={height} />}

      <svg className="absolute inset-0" width={width} height={height} style={{ pointerEvents: 'none' }}>
        {(annotations || []).map(a => (
          <g key={a.id} style={{ pointerEvents: tool === 'select' ? 'auto' : 'none', cursor: 'move' }}
            onPointerDown={e => dragStart(e, a, 'move')}>
            <OfficePdfViewMarkupShape a={a} width={width} height={height} selected={selectedId === a.id} />
            {selectedId === a.id && a.rect && (
              <rect x={a.rect.x * width - 3} y={a.rect.y * height - 3}
                width={Math.max(6, a.rect.w * width + 6)} height={Math.max(6, a.rect.h * height + 6)}
                fill="none" stroke="#8B5E34" strokeWidth="1" strokeDasharray="4 3" />
            )}
          </g>
        ))}
        {/* Redaction marks: MARKED, not applied. Applying is the tools panel's
            job and writes a new file with the content genuinely gone. */}
        {(redactions || []).filter(r => !r.applied).map(r => (
          <g key={r.id}>
            <rect x={r.rect.x * width} y={r.rect.y * height} width={r.rect.w * width} height={r.rect.h * height}
              fill="#161311" opacity="0.82" />
            <rect x={r.rect.x * width} y={r.rect.y * height} width={r.rect.w * width} height={r.rect.h * height}
              fill="none" stroke="#C0392B" strokeWidth="2" strokeDasharray="5 3" />
          </g>
        ))}
        {preview && markup && markup.shape !== 'path' && markup.shape !== 'poly' && (
          <rect x={preview.x * width} y={preview.y * height} width={preview.w * width} height={preview.h * height}
            fill={markup.shape === 'text' ? toolOpts.color : 'none'} fillOpacity="0.25"
            stroke={toolOpts.color} strokeWidth="1.5" strokeDasharray="4 3" />
        )}
        {preview && (cropping || redacting || placingField) && (
          <rect x={preview.x * width} y={preview.y * height} width={preview.w * width} height={preview.h * height}
            fill="none" stroke={redacting ? '#C0392B' : '#8B5E34'} strokeWidth="1.5" strokeDasharray="5 3" />
        )}
        {draft && markup && markup.shape === 'path' && (
          <path d={draft.points.map((q, i) => (i ? 'L' : 'M') + ' ' + q.x * width + ' ' + q.y * height).join(' ')}
            stroke={toolOpts.color} strokeWidth={toolOpts.weight} fill="none" />
        )}
        {draft && measuring && (
          <path d={(measuring === 'distance' ? [draft.start, draft.cur] : draft.points)
            .map((q, i) => (i ? 'L' : 'M') + ' ' + q.x * width + ' ' + q.y * height).join(' ')}
            stroke="#C0392B" strokeWidth="2" fill="none" strokeDasharray="6 3" />
        )}
        {poly && (
          <path d={[...poly.points, poly.cur].map((q, i) => (i ? 'L' : 'M') + ' ' + q.x * width + ' ' + q.y * height).join(' ')
            + (measuring === 'area' ? ' Z' : '')}
            stroke={measuring ? '#C0392B' : toolOpts.color} strokeWidth={measuring ? 2 : toolOpts.weight}
            fill="none" strokeDasharray="4 3" />
        )}
{/* The area OUTSIDE the crop is dimmed with four rectangles. A single
            rectangle with a hole punched in it would need a compositing mode
            SVG does not have; four boxes are exact and need no trickery. */}
        {cropDraft && (() => {
          const cx = cropDraft.x * width, cy = cropDraft.y * height;
          const cw = cropDraft.w * width, ch = cropDraft.h * height;
          const dim = { fill: '#161311', opacity: 0.4 };
          return (
            <g>
              <rect x="0" y="0" width={width} height={cy} {...dim} />
              <rect x="0" y={cy + ch} width={width} height={Math.max(0, height - cy - ch)} {...dim} />
              <rect x="0" y={cy} width={cx} height={ch} {...dim} />
              <rect x={cx + cw} y={cy} width={Math.max(0, width - cx - cw)} height={ch} {...dim} />
              <rect x={cx} y={cy} width={cw} height={ch} fill="none" stroke="#8B5E34" strokeWidth="2" />
            </g>
          );
        })()}
      </svg>

      {/* Text-carrying markups, the resize handle and the form/signature layers
          are HTML so they wrap, print and can be typed into. */}
      <div className="absolute inset-0" style={{ pointerEvents: 'none' }}>
        {(annotations || []).map(a => (
          <OfficePdfViewMarkupHtml key={a.id} a={a} width={width} height={height}
            selected={selectedId === a.id} onOpen={onOpenComment} />
        ))}
        {selectedId && (annotations || []).some(a => a.id === selectedId) && (() => {
          const a = annotations.find(x => x.id === selectedId);
          if (!a.rect || !(a.rect.w > 0)) return null;
          return (
            <div onPointerDown={e => dragStart(e, a, 'resize')}
              className="absolute w-3 h-3 bg-white border-2 border-[var(--leon-brown)] rounded-sm"
              style={{ left: (a.rect.x + a.rect.w) * width - 6, top: (a.rect.y + a.rect.h) * height - 6,
                       pointerEvents: 'auto', cursor: 'nwse-resize' }} />
          );
        })()}
        {(fields || []).map(f => (
          <OfficePdfViewFieldBox key={f.id} field={f} width={width} height={height} formMode={formMode}
            editable={editable} selected={selectedId === f.id}
            onSelect={() => onSelect(f.id)} onChange={v => onFieldChange(f.id, v)}
            onDrag={(e, mode) => dragStart(e, f, mode)} />
        ))}
        {(signatures || []).map(s => (
          <OfficePdfViewSignatureBox key={s.id} sig={s} width={width} height={height} />
        ))}
      </div>

      {poly && (
        <div className="absolute left-2 top-2 bg-[var(--leon-black)] text-white text-[11px] px-2 py-1 rounded"
          style={{ pointerEvents: 'auto' }}>
          {poly.points.length} point{poly.points.length === 1 ? '' : 's'} — double-click to finish
          <button className="ml-2 underline" onClick={finishPoly}>Finish</button>
          <button className="ml-2 underline" onClick={() => setPoly(null)}>Cancel</button>
        </div>
      )}
      {label && <div className="absolute -bottom-5 left-0 text-[10px] text-[var(--leon-black)]/40">{label}</div>}
    </div>
  );
}

// ── A form field on the page ──────────────────────────────────────────────
// Two genuinely different jobs, so two modes rather than one control that half
// does both. DESIGN places and names the box; FILL types into it. In design
// mode a required field is outlined but not enforced — enforcing "required" on
// the person building the form is nonsense.
function OfficePdfViewFieldBox({ field, width, height, formMode, editable, selected, onSelect, onChange, onDrag }) {
  const r = field.rect || { x: 0, y: 0, w: 0.2, h: 0.03 };
  const style = {
    position: 'absolute', left: r.x * width, top: r.y * height,
    width: Math.max(8, r.w * width), height: Math.max(8, r.h * height),
    pointerEvents: 'auto',
  };
  const empty = field.value === '' || field.value == null || field.value === false;
  const border = field.required && empty ? '#C0392B' : '#2563EB';

  if (formMode === 'design') {
    return (
      <div style={style} onPointerDown={e => onDrag(e, 'move')} onClick={e => { e.stopPropagation(); onSelect(); }}
        className={`text-[10px] font-semibold flex items-center px-1 overflow-hidden ${selected ? 'ring-2 ring-[var(--leon-brown)]' : ''}`}
        title={field.name || field.type}>
        <span className="absolute inset-0 pointer-events-none" style={{ border: '1.5px dashed ' + border, background: 'rgba(37,99,235,0.07)' }} />
        <span className="relative truncate" style={{ color: border }}>{field.name || field.type}{field.required ? ' *' : ''}</span>
        {selected && (
          <span onPointerDown={e => { e.stopPropagation(); onDrag(e, 'resize'); }}
            className="absolute -right-1 -bottom-1 w-3 h-3 bg-white border-2 border-[var(--leon-brown)] rounded-sm"
            style={{ cursor: 'nwse-resize' }} />
        )}
      </div>
    );
  }

  const common = { style: { width: '100%', height: '100%', fontSize: Math.max(9, r.h * height * 0.6),
                            border: '1px solid ' + border, background: 'rgba(255,255,255,0.92)', padding: '0 4px' } };
  if (field.type === 'Checkbox') {
    return (
      <div style={style} className="flex items-center justify-center">
        <input type="checkbox" checked={!!field.value} disabled={!editable || field.readOnly}
          onChange={e => onChange(e.target.checked)} style={{ width: '80%', height: '80%' }} />
      </div>
    );
  }
  if (field.type === 'Radio' || field.type === 'Dropdown') {
    return (
      <div style={style}>
        <select {...common} value={field.value || ''} disabled={!editable || field.readOnly}
          onChange={e => onChange(e.target.value)}>
          <option value=""></option>
          {(field.options || []).map(o => <option key={o} value={o}>{o}</option>)}
        </select>
      </div>
    );
  }
  if (field.type === 'Multiline') {
    return (
      <div style={style}>
        <textarea {...common} value={field.value || ''} disabled={!editable || field.readOnly}
          onChange={e => onChange(e.target.value)} />
      </div>
    );
  }
  if (field.type === 'Signature' || field.type === 'Initial') {
    // A signature field is not typed into here. It is claimed in the
    // Signatures panel, where who is signing and what kind of signature it is
    // are recorded — which is the part that matters.
    return (
      <div style={style} onClick={e => { e.stopPropagation(); onSelect(); }}
        className="flex items-center justify-center text-[10px] font-semibold"
        title={(field.signerRole || 'Signature') + ' — sign from the Signatures panel'}>
        <span className="absolute inset-0 pointer-events-none" style={{ border: '1.5px solid #8B5E34', background: 'rgba(139,94,52,0.06)' }} />
        <span className="relative text-[var(--leon-brown)] truncate px-1">{field.signerRole || field.name || field.type}</span>
      </div>
    );
  }
  return (
    <div style={style}>
      <input {...common} type={field.type === 'Date' ? 'date' : field.type === 'Number' ? 'number' : 'text'}
        value={field.value || ''} disabled={!editable || field.readOnly}
        onChange={e => onChange(e.target.value)} />
    </div>
  );
}

// A placed signature. It is a picture of an approval, and it says which kind it
// is on its own face — a reader should never have to open a panel to find out
// whether "J. Nguyen" was drawn with a mouse or is an authenticated approval.
function OfficePdfViewSignatureBox({ sig, width, height }) {
  const r = sig.rect || { x: 0, y: 0, w: 0.22, h: 0.06 };
  return (
    <div style={{ position: 'absolute', left: r.x * width, top: r.y * height,
                  width: r.w * width, height: r.h * height, pointerEvents: 'none' }}
      className="border-b-2 border-[var(--leon-black)] flex flex-col justify-end overflow-hidden">
      {sig.imageRef
        ? <img src={sig.imageRef} alt="" className="object-contain max-h-[70%] self-start" />
        : <span className="self-start font-bold" style={{
            fontFamily: sig.kind === 'Typed Signature' ? 'Segoe Script, Brush Script MT, cursive' : 'inherit',
            fontSize: Math.max(10, r.h * height * 0.42) }}>{sig.typedName || sig.signerName}</span>}
      <span className="text-[8px] leading-tight text-[var(--leon-black)]/55 truncate">
        {sig.role ? sig.role + ' · ' : ''}{sig.signerName}{sig.signedDate ? ' · ' + sig.signedDate : ''}
        {sig.kind === 'LEON Approval' ? ' · LEON Approval' : ''}
      </span>
    </div>
  );
}

// ═══════════════════════════════════════════════════════ layout + virtualizing
// Rows, not pages, because two-page spreads are rows of two and everything
// downstream — measuring, virtualizing, scrolling to a page — then works the
// same in all four layouts.
function officePdfViewBuildRows(plan, sizes, zoom, perRow) {
  const rows = [];
  let top = 0;
  for (let i = 0; i < plan.length; i += perRow) {
    const items = [];
    let rowH = 0, rowW = 0;
    for (let k = 0; k < perRow && i + k < plan.length; k++) {
      const p = plan[i + k];
      const nat = sizes[p.assetId + ':' + p.index] || PDFVIEW_DEFAULT_PAGE;
      const turned = ((p.rotate || 0) % 180) !== 0;
      const wPt = turned ? nat.height : nat.width;
      const hPt = turned ? nat.width : nat.height;
      const w = Math.round(wPt * PDFVIEW_CSS_PER_PT * zoom);
      const h = Math.round(hPt * PDFVIEW_CSS_PER_PT * zoom);
      items.push({ page: p, w, h, rowIndex: rows.length });
      rowH = Math.max(rowH, h); rowW += w + (k ? PDFVIEW_GAP : 0);
    }
    rows.push({ index: rows.length, items, height: rowH, width: rowW, top });
    top += rowH + PDFVIEW_GAP;
  }
  return { rows, total: top };
}

// The zoom a "fit" mode works out to. It is derived from the WIDEST page in the
// document rather than the current one, so scrolling from a Letter page to an
// A0 drawing does not silently rescale everything under the reader.
function officePdfViewFitZoom(mode, plan, sizes, box, perRow) {
  if (!box || !box.w) return 1;
  let wPt = PDFVIEW_DEFAULT_PAGE.width, hPt = PDFVIEW_DEFAULT_PAGE.height;
  plan.forEach(p => {
    const nat = sizes[p.assetId + ':' + p.index];
    if (!nat) return;
    const turned = ((p.rotate || 0) % 180) !== 0;
    wPt = Math.max(wPt, turned ? nat.height : nat.width);
    hPt = Math.max(hPt, turned ? nat.width : nat.height);
  });
  const avail = (box.w - 48 - (perRow - 1) * PDFVIEW_GAP) / perRow;
  const byWidth = avail / (wPt * PDFVIEW_CSS_PER_PT);
  if (mode === 'fitWidth') return officePdfViewClamp(byWidth, 0.05, 8);
  const byHeight = (box.h - 40) / (hPt * PDFVIEW_CSS_PER_PT);
  return officePdfViewClamp(Math.min(byWidth, byHeight), 0.05, 8);
}

// The scrolling surface. VIRTUALIZATION IS NOT OPTIONAL HERE: a 300-page
// specification is 300 canvases, and drawing them all costs about a gigabyte
// and a frozen tab. Only the rows near the viewport are drawn; every other row
// is a placeholder box at its true height, which is what stops the scrollbar
// from jumping as pages arrive.
function OfficePdfViewSurface({
  plan, sizes, zoomMode, zoomValue, layout, onZoomValue, onCurrentPage, jumpTo,
  body, annotationsFor, fieldsFor, signaturesFor, redactionsFor, hitsFor, flashHit,
  tool, toolOpts, selectedId, editable, textFor, showText, formMode, cropDraft, onCropDraft,
  onSelect, onCreate, onUpdate, onOpenComment, onFieldChange, fullScreen,
}) {
  const scrollRef = useRef(null);
  const [box, setBox] = useState({ w: 0, h: 0 });
  const [scrollTop, setScrollTop] = useState(0);
  const [cursorRow, setCursorRow] = useState(0);
  const rafRef = useRef(0);

  const lay = PDFVIEW_LAYOUTS.find(l => l.key === layout) || PDFVIEW_LAYOUTS[1];
  const perRow = lay.perRow;

  useEffect(() => {
    const el = scrollRef.current;
    if (!el) return undefined;
    const read = () => setBox({ w: el.clientWidth, h: el.clientHeight });
    read();
    if (typeof ResizeObserver === 'function') {
      const ro = new ResizeObserver(read);
      ro.observe(el);
      return () => ro.disconnect();
    }
    window.addEventListener('resize', read);
    return () => window.removeEventListener('resize', read);
  }, [fullScreen]);

  // The fit modes need the box, so they are resolved here and reported upward
  // rather than being recomputed in three places.
  const fitZoom = useMemo(
    () => (zoomMode === 'fixed' ? zoomValue : officePdfViewFitZoom(zoomMode, plan, sizes, box, perRow)),
    [zoomMode, zoomValue, plan, sizes, box.w, box.h, perRow]);
  useEffect(() => { if (zoomMode !== 'fixed') onZoomValue(fitZoom); }, [fitZoom, zoomMode]);

  const { rows, total } = useMemo(
    () => officePdfViewBuildRows(plan, sizes, fitZoom, perRow),
    [plan, sizes, fitZoom, perRow]);

  function onScroll() {
    if (rafRef.current) return;
    rafRef.current = requestAnimationFrame(() => {
      rafRef.current = 0;
      const el = scrollRef.current;
      if (!el) return;
      setScrollTop(el.scrollTop);
    });
  }

  // Which page is "the" page, for the page-number box and the thumbnail
  // highlight: the first row whose bottom is still below the top of the window.
  useEffect(() => {
    if (!rows.length) return;
    let idx = 0;
    for (let i = 0; i < rows.length; i++) { if (rows[i].top + rows[i].height * 0.4 >= scrollTop) { idx = i; break; } idx = i; }
    const row = lay.paged ? rows[cursorRow] : rows[idx];
    if (row && row.items[0]) onCurrentPage(row.items[0].page.planIndex);
  }, [scrollTop, rows, cursorRow, lay.paged]);

  // Jumping to a page. In a paged layout that means changing the row; in a
  // continuous one it means scrolling to its top.
  useEffect(() => {
    if (!jumpTo || jumpTo.planIndex == null || !rows.length) return;
    const rowIdx = rows.findIndex(r => r.items.some(it => it.page.planIndex === jumpTo.planIndex));
    if (rowIdx < 0) return;
    if (lay.paged) setCursorRow(rowIdx);
    else if (scrollRef.current) scrollRef.current.scrollTop = Math.max(0, rows[rowIdx].top - 12);
  }, [jumpTo && jumpTo.nonce, rows.length, lay.paged]);

  useEffect(() => { setCursorRow(c => officePdfViewClamp(c, 0, Math.max(0, rows.length - 1))); }, [rows.length]);

  // TWO BANDS, not one. The DRAW band is the viewport plus a little, and those
  // pages are rendered so scrolling a screen either way is already painted. The
  // MOUNT band is wider and its extra rows are placeholders at the page's true
  // aspect ratio — cheap, and they mean a row is already in the DOM at the right
  // height before it is drawn, so nothing shifts under the reader.
  let vFirst = 0, vLast = 0, drawFirst = 0, drawLast = 0, mountFirst = 0, mountLast = 0;
  if (lay.paged) {
    vFirst = vLast = drawFirst = drawLast = mountFirst = mountLast = cursorRow;
  } else {
    const top = scrollTop, bottom = scrollTop + (box.h || 800);
    vFirst = rows.length; vLast = -1;
    rows.forEach(r => {
      if (r.top + r.height >= top && r.top <= bottom) { vFirst = Math.min(vFirst, r.index); vLast = Math.max(vLast, r.index); }
    });
    if (vLast < 0) { vFirst = 0; vLast = Math.min(rows.length - 1, 1); }
    drawFirst = Math.max(0, vFirst - PDFVIEW_OVERSCAN);
    drawLast = Math.min(rows.length - 1, vLast + PDFVIEW_OVERSCAN);
    mountFirst = Math.max(0, vFirst - PDFVIEW_OVERSCAN * 3);
    mountLast = Math.min(rows.length - 1, vLast + PDFVIEW_OVERSCAN * 3);
  }

  const shown = rows.slice(mountFirst, mountLast + 1);

  return (
    <div className="relative flex-1 min-h-0">
      <div ref={scrollRef} onScroll={onScroll}
        className="h-full overflow-auto bg-[#4a4441] px-6 py-4"
        style={{ minHeight: fullScreen ? 0 : 420 }}>
        {!plan.length ? (
          <div className="text-center text-white/60 text-sm py-20">
            This document has no pages yet.
            <div className="text-white/40 text-xs mt-1">Add one with <b>+ Insert a PDF</b> in the Pages panel.</div>
          </div>
        ) : lay.paged ? (
          <div className="flex flex-col items-center gap-2">
            {shown.map(row => (
              <div key={row.index} className="flex items-start" style={{ gap: PDFVIEW_GAP }}>
                {row.items.map(it => (
                  <OfficePdfViewPage key={it.page.id} page={it.page} planIndex={it.page.planIndex}
                    width={it.w} height={it.h} render pageNumber={it.page.planIndex + 1}
                    label={(body.pageLabels && body.pageLabels[it.page.id]) || ('Page ' + (it.page.planIndex + 1))}
                    annotations={annotationsFor(it.page.planIndex)} fields={fieldsFor(it.page.planIndex)}
                    signatures={signaturesFor(it.page.planIndex)} redactions={redactionsFor(it.page.planIndex)}
                    hits={hitsFor(it.page.planIndex)} flashHit={flashHit}
                    tool={tool} toolOpts={toolOpts} selectedId={selectedId} editable={editable}
                    pageText={textFor(it.page.planIndex)} showText={showText} formMode={formMode}
                    cropDraft={cropDraft && cropDraft.planIndex === it.page.planIndex ? cropDraft.rect : null}
                    onCropDraft={r => onCropDraft({ planIndex: it.page.planIndex, rect: r })}
                    onSelect={onSelect} onCreate={s => onCreate(it.page.planIndex, s)}
                    onUpdate={onUpdate} onOpenComment={onOpenComment}
                    onFieldChange={onFieldChange} />
                ))}
              </div>
            ))}
            <div className="flex items-center gap-2 mt-3 text-white/80 text-xs">
              <Button size="sm" variant="ghost" className="!text-white" disabled={cursorRow <= 0}
                onClick={() => setCursorRow(c => Math.max(0, c - 1))}>← Previous</Button>
              <span>{cursorRow + 1} / {rows.length}</span>
              <Button size="sm" variant="ghost" className="!text-white" disabled={cursorRow >= rows.length - 1}
                onClick={() => setCursorRow(c => Math.min(rows.length - 1, c + 1))}>Next →</Button>
            </div>
          </div>
        ) : (
          // One tall spacer holds the true document height; the rows that are
          // actually mounted are positioned inside it. Absolute positioning (as
          // opposed to top/bottom padding) keeps a row's offset exact even when
          // the rows above it are different heights, which they are the moment
          // a drawing sheet sits between two letter pages.
          <div style={{ position: 'relative', height: total }}>
            {shown.map(row => (
              <div key={row.index} className="absolute left-0 right-0 flex justify-center items-start"
                style={{ top: row.top, gap: PDFVIEW_GAP }}>
                {row.items.map(it => (
                  <OfficePdfViewPage key={it.page.id} page={it.page} planIndex={it.page.planIndex}
                    width={it.w} height={it.h}
                    render={row.index >= drawFirst && row.index <= drawLast}
                    pageNumber={it.page.planIndex + 1}
                    label={(body.pageLabels && body.pageLabels[it.page.id]) || ('Page ' + (it.page.planIndex + 1))}
                    annotations={annotationsFor(it.page.planIndex)} fields={fieldsFor(it.page.planIndex)}
                    signatures={signaturesFor(it.page.planIndex)} redactions={redactionsFor(it.page.planIndex)}
                    hits={hitsFor(it.page.planIndex)} flashHit={flashHit}
                    tool={tool} toolOpts={toolOpts} selectedId={selectedId} editable={editable}
                    pageText={textFor(it.page.planIndex)} showText={showText} formMode={formMode}
                    cropDraft={cropDraft && cropDraft.planIndex === it.page.planIndex ? cropDraft.rect : null}
                    onCropDraft={r => onCropDraft({ planIndex: it.page.planIndex, rect: r })}
                    onSelect={onSelect} onCreate={s => onCreate(it.page.planIndex, s)}
                    onUpdate={onUpdate} onOpenComment={onOpenComment}
                    onFieldChange={onFieldChange} />
                ))}
              </div>
            ))}
          </div>
        )}
      </div>
      <div className="absolute right-3 bottom-3 text-[10px] text-white/45 pointer-events-none">
        {Math.max(0, drawLast - drawFirst + 1)} of {rows.length} row{rows.length === 1 ? '' : 's'} drawn ·
        {' '}{shown.length} mounted · cache {officePdfBytesLabel(officePdfViewPageCache.bytes)}
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════ left panel: Pages
// Every operation here rewrites the ORDER PLAN and nothing else. That is why
// deleting forty pages out of a three-hundred-page set is instant and why one
// Undo puts them back — there is no file being rewritten to undo.
function OfficePdfViewPagesPanel({
  body, plan, sizes, editable, current, selection, setSelection,
  onJump, onRotate, onDelete, onDuplicate, onExtract, onInsert, onReplace, onCrop, onProps, onReorder,
}) {
  const listRef = useRef(null);
  const [top, setTop] = useState(0);
  const [viewH, setViewH] = useState(600);
  const [menu, setMenu] = useState(null);
  const [dragOver, setDragOver] = useState(null);
  const dragFrom = useRef(null);
  const fileRef = useRef(null);
  const insertMode = useRef({ mode: 'after', at: 0 });

  const ROW = 152;   // fixed row height keeps the virtual window arithmetic honest
  useEffect(() => {
    const el = listRef.current;
    if (!el) return undefined;
    const read = () => setViewH(el.clientHeight || 600);
    read();
    if (typeof ResizeObserver === 'function') { const ro = new ResizeObserver(read); ro.observe(el); return () => ro.disconnect(); }
    return undefined;
  }, []);
  const first = Math.max(0, Math.floor(top / ROW) - 3);
  const last = Math.min(plan.length - 1, Math.ceil((top + viewH) / ROW) + 3);

  function click(e, i) {
    if (e.shiftKey && selection.length) {
      const anchor = selection[selection.length - 1];
      const [a, b] = anchor < i ? [anchor, i] : [i, anchor];
      const range = [];
      for (let k = a; k <= b; k++) range.push(plan[k].planIndex);
      setSelection([...new Set([...selection, ...range])]);
    } else if (e.metaKey || e.ctrlKey) {
      const pi = plan[i].planIndex;
      setSelection(selection.includes(pi) ? selection.filter(x => x !== pi) : [...selection, pi]);
    } else {
      setSelection([plan[i].planIndex]);
      onJump(plan[i].planIndex);
    }
  }
  function pickFile(mode, at) {
    insertMode.current = { mode, at };
    if (fileRef.current) { fileRef.current.value = ''; fileRef.current.click(); }
  }
  function onFile(e) {
    const f = e.target.files && e.target.files[0];
    if (!f) return;
    const { mode, at } = insertMode.current;
    if (mode === 'replace') onReplace(at, f); else onInsert(at, f, mode);
  }

  const sel = selection;
  const some = sel.length > 0;

  return (
    <div className="flex flex-col h-full min-h-0">
      <input ref={fileRef} type="file" accept="application/pdf" className="hidden" onChange={onFile} />
      <div className="flex flex-wrap items-center gap-1 p-2 border-b border-[var(--leon-line)]">
        <IconAction icon="↺" title="Rotate left" disabled={!editable || !some} onClick={() => onRotate(sel, -90)} />
        <IconAction icon="↻" title="Rotate right" disabled={!editable || !some} onClick={() => onRotate(sel, 90)} />
        <IconAction icon="⧉" title="Duplicate" disabled={!editable || !some} onClick={() => onDuplicate(sel)} />
        <IconAction icon="⬇" title="Extract the selected pages as a new PDF" disabled={!some} onClick={() => onExtract(sel)} />
        <IconAction icon="⬚" title="Crop" disabled={!editable || sel.length !== 1} onClick={() => onCrop(sel[0])} />
        <IconAction icon="ⓘ" title="Page properties" disabled={sel.length !== 1} onClick={() => onProps(sel[0])} />
        <IconAction icon="🗑" title="Delete" disabled={!editable || !some} onClick={() => onDelete(sel)} />
        <span className="ml-auto text-[11px] text-[var(--leon-black)]/45">
          {some ? sel.length + ' selected' : plan.length + ' pages'}
        </span>
      </div>
      <div className="flex items-center gap-1 px-2 py-1 border-b border-[var(--leon-line)] text-[11px]">
        <button className="underline text-[var(--leon-brown)]" onClick={() => setSelection(plan.map(p => p.planIndex))}>All</button>
        <button className="underline text-[var(--leon-brown)]" onClick={() => setSelection([])}>None</button>
        {editable && <button className="ml-auto underline text-[var(--leon-brown)]"
          onClick={() => pickFile('after', plan.length ? plan[plan.length - 1].planIndex : 0)}>+ Insert a PDF</button>}
      </div>

      <div ref={listRef} className="flex-1 min-h-0 overflow-auto p-2"
        onScroll={e => setTop(e.currentTarget.scrollTop)}>
        <div style={{ position: 'relative', height: Math.max(0, plan.length * ROW) }}>
          {plan.slice(Math.max(0, first), last + 1).map((p, k) => {
            const i = Math.max(0, first) + k;
            const nat = sizes[p.assetId + ':' + p.index] || PDFVIEW_DEFAULT_PAGE;
            const turned = ((p.rotate || 0) % 180) !== 0;
            const ar = (turned ? nat.height : nat.width) / (turned ? nat.width : nat.height);
            const h = 112, w = Math.max(24, Math.round(h * ar));
            const picked = sel.includes(p.planIndex);
            return (
              <div key={p.id} style={{ position: 'absolute', top: i * ROW, left: 0, right: 0, height: ROW - 8 }}
                draggable={editable}
                onDragStart={() => { dragFrom.current = i; }}
                onDragOver={e => { e.preventDefault(); setDragOver(i); }}
                onDragLeave={() => setDragOver(o => (o === i ? null : o))}
                onDrop={e => {
                  e.preventDefault();
                  const from = dragFrom.current;
                  setDragOver(null); dragFrom.current = null;
                  if (from == null || from === i) return;
                  onReorder(sel.length > 1 && sel.includes(plan[from].planIndex) ? sel : [plan[from].planIndex], p.planIndex);
                }}
                onClick={e => click(e, i)}
                onContextMenu={e => { e.preventDefault(); if (!sel.includes(p.planIndex)) setSelection([p.planIndex]); setMenu({ x: e.clientX, y: e.clientY, planIndex: p.planIndex }); }}
                className={`flex flex-col items-center justify-center rounded-md cursor-pointer border-2 ${
                  picked ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-transparent hover:bg-[var(--leon-cream)]'
                } ${dragOver === i ? 'ring-2 ring-[var(--leon-brown)]' : ''} ${current === p.planIndex ? 'outline outline-1 outline-[var(--leon-brown-light,#b08968)]' : ''}`}>
                <div className="relative bg-white border border-[var(--leon-line)]" style={{ width: w, height: h }}>
                  <OfficePdfViewCanvas assetId={p.assetId} index={p.index} rotate={p.rotate} width={w} height={h} thumb />
                  {p.crop && <span className="absolute top-0 right-0 bg-[var(--leon-brown)] text-white text-[8px] px-1">cropped</span>}
                </div>
                <span className="text-[10px] mt-1 text-[var(--leon-black)]/60">
                  {(body.pageLabels && body.pageLabels[p.id]) || (p.planIndex + 1)}
                </span>
              </div>
            );
          })}
        </div>
      </div>

      {menu && (
        <>
          <div className="fixed inset-0 z-40" onClick={() => setMenu(null)} onContextMenu={e => { e.preventDefault(); setMenu(null); }} />
          <div className="fixed z-50 bg-white border border-[var(--leon-line)] rounded-lg shadow-xl py-1 text-sm w-56"
            style={{ left: Math.min(menu.x, window.innerWidth - 240), top: Math.min(menu.y, window.innerHeight - 340) }}>
            {[
              ['Go to this page', () => onJump(menu.planIndex), true],
              ['Rotate left', () => onRotate(sel, -90), editable],
              ['Rotate right', () => onRotate(sel, 90), editable],
              ['Duplicate', () => onDuplicate(sel), editable],
              ['Extract as a new PDF…', () => onExtract(sel), true],
              ['Crop…', () => onCrop(menu.planIndex), editable],
              ['Replace with a PDF…', () => pickFile('replace', menu.planIndex), editable],
              ['Insert a PDF before', () => pickFile('before', menu.planIndex), editable],
              ['Insert a PDF after', () => pickFile('after', menu.planIndex), editable],
              ['Page properties…', () => onProps(menu.planIndex), true],
              ['Delete', () => onDelete(sel), editable],
            ].map(([label, fn, on]) => (
              <button key={label} disabled={!on} onClick={() => { setMenu(null); fn(); }}
                className="w-full text-left px-3 py-1.5 hover:bg-[var(--leon-cream)] disabled:opacity-35 disabled:hover:bg-transparent">
                {label}
              </button>
            ))}
          </div>
        </>
      )}
    </div>
  );
}

// ═══════════════════════════════════════════════════ left panel: Bookmarks
// A nested list, edited in place. The PDF's OWN outline can be pulled in when
// it has one — an issued specification usually does, and retyping ninety
// section headings is not work anyone should be asked to do.
function OfficePdfViewBookmarkRow({ bm, depth, editable, onJump, onRename, onDelete, onMove, onNest, onAdd }) {
  const [editing, setEditing] = useState(false);
  const [text, setText] = useState(bm.title);
  return (
    <div>
      <div className="flex items-center gap-1 group hover:bg-[var(--leon-cream)] rounded px-1"
        style={{ paddingLeft: 4 + depth * 14 }}>
        {editing ? (
          <input autoFocus value={text} onChange={e => setText(e.target.value)}
            onBlur={() => { setEditing(false); onRename(bm.id, text.trim() || bm.title); }}
            onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { setText(bm.title); setEditing(false); } }}
            className="flex-1 text-sm border border-[var(--leon-line)] rounded px-1 py-0.5" />
        ) : (
          <button className="flex-1 text-left text-sm py-1 truncate" onClick={() => onJump(bm.page)} title={bm.title}>
            <span className="text-[var(--leon-black)]/35 mr-1.5 text-[10px]">p{(bm.page || 0) + 1}</span>{bm.title}
          </button>
        )}
        {editable && (
          <span className="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 shrink-0">
            <IconBtn title="Rename" onClick={() => setEditing(true)}>✎</IconBtn>
            <IconBtn title="Move up" onClick={() => onMove(bm.id, -1)}>↑</IconBtn>
            <IconBtn title="Move down" onClick={() => onMove(bm.id, 1)}>↓</IconBtn>
            <IconBtn title="Nest under the one above" onClick={() => onNest(bm.id, 'in')}>→</IconBtn>
            <IconBtn title="Move out one level" onClick={() => onNest(bm.id, 'out')}>←</IconBtn>
            <IconBtn title="Add a child bookmark here" onClick={() => onAdd(bm.id)}>＋</IconBtn>
            <IconBtn title="Delete" onClick={() => onDelete(bm.id)}>🗑</IconBtn>
          </span>
        )}
      </div>
      {(bm.children || []).map(c => (
        <OfficePdfViewBookmarkRow key={c.id} bm={c} depth={depth + 1} editable={editable}
          onJump={onJump} onRename={onRename} onDelete={onDelete} onMove={onMove} onNest={onNest} onAdd={onAdd} />
      ))}
    </div>
  );
}

// ═══════════════════════════════════════════════════════ left panel: Search
// The index status is reported as it really is. A document that has not been
// read yet says "Not processed" rather than quietly returning no results, which
// is indistinguishable from "the words are not in here".
// The query and the hits live in the EDITOR, not here: the pages have to paint
// the matches too, and a second copy of the search state in this panel would
// drift the moment the panel unmounted while the highlights stayed on screen.
function OfficePdfViewSearchPanel({ index, indexState, onBuild, onJump, flashHit, setFlashHit,
                                    query, setQuery, caseSensitive, setCaseSensitive,
                                    wholeWord, setWholeWord, hits }) {
  const q = query;
  const setQ = setQuery;

  const byPage = useMemo(() => {
    const m = new Map();
    hits.forEach(h => { if (!m.has(h.page)) m.set(h.page, []); m.get(h.page).push(h); });
    return [...m.entries()].sort((a, b) => a[0] - b[0]);
  }, [hits]);

  return (
    <div className="flex flex-col h-full min-h-0">
      <div className="p-2 border-b border-[var(--leon-line)] space-y-2">
        <TextInput placeholder="Search this document…" value={q} onChange={e => setQ(e.target.value)} />
        <div className="flex items-center gap-3 text-[11px]">
          <label className="flex items-center gap-1"><input type="checkbox" checked={caseSensitive} onChange={e => setCaseSensitive(e.target.checked)} /> Match case</label>
          <label className="flex items-center gap-1"><input type="checkbox" checked={wholeWord} onChange={e => setWholeWord(e.target.checked)} /> Whole word</label>
        </div>
        <div className="text-[11px] text-[var(--leon-black)]/55">
          Text index: <b>{indexState.status}</b>
          {indexState.status === 'Processing' && indexState.total ? ` — ${indexState.done} of ${indexState.total} pages` : ''}
          {indexState.status !== 'Complete' && indexState.status !== 'Processing' && (
            <button className="ml-2 underline text-[var(--leon-brown)]" onClick={onBuild}>Build it now</button>
          )}
          {indexState.status === 'Complete' && (
            <button className="ml-2 underline text-[var(--leon-brown)]" onClick={onBuild}>Rebuild</button>
          )}
        </div>
        {indexState.scanned && (
          <div className="text-[11px] text-[#8a6d1f] bg-[#fbf6e9] border border-[#e6dcc6] rounded px-2 py-1.5">
            Most pages here carry no text layer — this is a scan. Searching can only find words that exist
            as text, and recognising the words in an image needs an OCR engine, which is not installed.
          </div>
        )}
      </div>
      <div className="flex-1 min-h-0 overflow-auto">
        {!q.trim() ? <EmptyState text="Type to search." />
          : !hits.length ? <EmptyState text={indexState.status === 'Complete' ? 'No matches.' : 'Build the index first.'} />
          : (
            <div className="p-2 space-y-3">
              <div className="text-[11px] text-[var(--leon-black)]/50">{hits.length} match{hits.length === 1 ? '' : 'es'} on {byPage.length} page{byPage.length === 1 ? '' : 's'}</div>
              {byPage.map(([page, list]) => (
                <div key={page}>
                  <div className="text-[11px] font-bold text-[var(--leon-black)]/60 mb-1">Page {page + 1} · {list.length}</div>
                  {list.map(h => (
                    <button key={h.key} onClick={() => { onJump(h.page); setFlashHit(h.key); }}
                      className={`block w-full text-left text-[11px] leading-snug rounded px-2 py-1.5 mb-1 border ${
                        flashHit === h.key ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)] hover:bg-[var(--leon-cream)]'}`}>
                      …{h.context}…
                    </button>
                  ))}
                </div>
              ))}
            </div>
          )}
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════════ left panel: Package
// Read-only here on purpose. Building a binder — deciding what goes in it, in
// what order, with which dividers — is an assembly job and lives in the tools
// panel. This is the reader's view of the result: what sections exist and where
// they start.
function OfficePdfViewPackagePanel({ body, plan, onJump, onOpenTools }) {
  const sections = body.packageSections || [];
  const posOf = (pageId) => {
    const i = plan.findIndex(p => p.id === pageId);
    return i < 0 ? null : plan[i].planIndex;
  };
  return (
    <div className="p-3 space-y-3 overflow-auto h-full">
      <p className="text-[11px] text-[var(--leon-black)]/55 leading-snug">
        The sections of this package. Building and reordering them is done in <b>Organize → Package</b>;
        this list is here so you can jump to a section while reading.
      </p>
      {!sections.length ? (
        <EmptyState text="This document is not organised into package sections." />
      ) : sections.map((s, i) => {
        const firstPage = (s.pageIds || []).map(posOf).filter(v => v != null).sort((a, b) => a - b)[0];
        return (
          <button key={s.id || i} onClick={() => firstPage != null && onJump(firstPage)}
            className="w-full text-left rounded-lg border border-[var(--leon-line)] px-3 py-2 hover:bg-[var(--leon-cream)]">
            <div className="text-sm font-semibold">{i + 1}. {s.title || 'Untitled section'}</div>
            <div className="text-[11px] text-[var(--leon-black)]/50">
              {(s.pageIds || []).length} page{(s.pageIds || []).length === 1 ? '' : 's'}
              {firstPage != null ? ` · starts at page ${firstPage + 1}` : ' · no pages in this document'}
              {s.divider ? ' · has a divider' : ''}
            </div>
          </button>
        );
      })}
      <Button size="sm" variant="outline" onClick={onOpenTools}>Open the package tools</Button>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════ honest notices
// One component per claim the app must not make, so the wording cannot drift
// into six slightly different half-promises.
function OfficePdfViewAiPanel({ suggestion, category, onAcceptSuggestion, editable }) {
  return (
    <div className="space-y-3">
      <div className="rounded-lg border border-dashed border-[var(--leon-line)] p-3">
        <div className="text-sm font-bold mb-1">✨ The Hub has no AI of its own</div>
        <p className="text-[11px] text-[var(--leon-black)]/60 leading-snug">
          It runs entirely in this browser with no backend, and an API key cannot live in a page anyone
          can view the source of &mdash; so nothing here answers by itself, and nothing returns an invented
          answer while it waits. What each control does is write the request properly and open Claude or
          ChatGPT with it. <b>Attach the PDF in that window</b>; a link cannot carry a document.
        </p>
      </div>
      <div className="space-y-2">
        {PDFVIEW_AI_OPS.map(op => (
          <div key={op.key} className="rounded-md border border-dashed border-[var(--leon-line)] px-3 py-2">
            <div className="flex items-center gap-2">
              <AiHandoffButton label={op.label} title={op.label}
                brief={buildAiHandoffBrief({
                  task: 'I need help with this: ' + op.label + '.',
                  detail: 'The intent is to ' + op.would,
                  context: [{ label: 'Raised from', value: 'LEON PDF' }],
                  files: ['The PDF this concerns'],
                })} />
            </div>
            <p className="text-[11px] text-[var(--leon-black)]/50 mt-1 leading-snug">Would {op.would}</p>
          </div>
        ))}
      </div>
      {/* The one thing here that is real, and it says what it is. */}
      <div className="rounded-lg border border-[var(--leon-line)] p-3">
        <div className="text-sm font-bold mb-1">Suggested category</div>
        <p className="text-[11px] text-[var(--leon-black)]/60 leading-snug mb-2">
          Counted from words in the extracted text — a keyword rule, not a model, and not verified.
          It is a starting point for whoever files the document, never an answer.
        </p>
        {!suggestion ? (
          <div className="text-[11px] text-[var(--leon-black)]/45">
            Nothing suggested. Build the text index first, or the wording here matches no category strongly enough.
          </div>
        ) : (
          <div className="flex items-center gap-2 flex-wrap">
            <Badge tone="neutral">{suggestion.category}</Badge>
            <span className="text-[11px] text-[var(--leon-black)]/45">score {suggestion.score}</span>
            {category === suggestion.category
              ? <span className="text-[11px] text-[var(--leon-black)]/45">— already filed here</span>
              : <Button size="sm" variant="outline" disabled={!editable} onClick={onAcceptSuggestion}>Use it</Button>}
          </div>
        )}
      </div>
    </div>
  );
}

function OfficePdfViewOcrPanel({ body, indexState, onBuild, editable, onSetStatus }) {
  const scanned = indexState.scanned;
  return (
    <div className="space-y-3">
      <div className="grid grid-cols-2 gap-2 text-[11px]">
        <div className="rounded-md border border-[var(--leon-line)] px-2 py-1.5">
          <div className="text-[var(--leon-black)]/45 uppercase tracking-wide text-[10px]">Recorded status</div>
          <div className="font-bold">{body.ocrStatus || 'Not Processed'}</div>
        </div>
        <div className="rounded-md border border-[var(--leon-line)] px-2 py-1.5">
          <div className="text-[var(--leon-black)]/45 uppercase tracking-wide text-[10px]">Text layer found</div>
          <div className="font-bold">
            {indexState.status !== 'Complete' ? 'Not checked yet'
              : scanned ? 'No — this is a scan' : 'Yes — the words are real text'}
          </div>
        </div>
      </div>
      <p className="text-[11px] text-[var(--leon-black)]/60 leading-snug">
        Detecting a scan is real and is done above: a page with no text objects at all is a picture of
        words, and that is what the check looks for. <b>Recognising</b> those words is a different thing and
        needs an OCR engine — none is bundled with the Hub and one cannot be added from this page, so the
        button below stays off rather than pretending to run.
      </p>
      <div className="flex items-center gap-2 flex-wrap">
        <Button size="sm" variant="outline" onClick={onBuild}>
          {indexState.status === 'Complete' ? 'Re-check the text layer' : 'Check the text layer'}
        </Button>
        <Button size="sm" disabled onClick={() => {}}>Run OCR</Button>
        <span className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/35">No engine installed</span>
      </div>
      {editable && (
        <Field label="Record the OCR status" hint="Set this by hand when a page was recognised outside the Hub.">
          <Select value={body.ocrStatus || 'Not Processed'} onChange={e => onSetStatus(e.target.value)}>
            {PDF_OCR_STATUSES.map(s => <option key={s}>{s}</option>)}
          </Select>
        </Field>
      )}
    </div>
  );
}

// ═══════════════════════════════════════════════════ right panel: Comments
// A comment IS an annotation — there is no second list. A sticky note in the
// margin and a row in this panel are one record, so replying here and reading
// it on the page can never disagree.
function OfficePdfViewCommentCard({ a, team, editable, selected, onSelect, onUpdate, onDelete, onReply, currentUserName }) {
  const [reply, setReply] = useState('');
  const [editing, setEditing] = useState(false);
  const [text, setText] = useState(a.text || '');
  return (
    <div onClick={() => onSelect(a.id)}
      className={`rounded-lg border px-3 py-2 cursor-pointer ${selected ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)] bg-white'}`}>
      <div className="flex items-center gap-2 mb-1">
        <Avatar name={a.author || 'Unknown'} size={22} />
        <span className="text-xs font-bold truncate">{a.author || 'Unknown'}</span>
        <Badge tone={a.state === 'Completed' || a.state === 'Accepted' ? 'green' : a.state === 'Rejected' || a.state === 'Cancelled' ? 'red' : 'neutral'}>{a.state || 'Open'}</Badge>
        <span className="ml-auto text-[10px] text-[var(--leon-black)]/40 shrink-0">p{(a.page || 0) + 1} · {a.date}</span>
      </div>
      <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/35 mb-1">{a.type}</div>
      {a.quote && <div className="text-[11px] italic text-[var(--leon-black)]/50 border-l-2 border-[var(--leon-line)] pl-2 mb-1 line-clamp-3">“{a.quote}”</div>}
      {editing ? (
        <TextArea rows={3} value={text} onChange={e => setText(e.target.value)}
          onBlur={() => { setEditing(false); if (text !== a.text) onUpdate(a.id, { text }); }} />
      ) : (
        <div className="text-sm whitespace-pre-wrap leading-snug" onDoubleClick={() => editable && setEditing(true)}>
          {a.text || <span className="text-[var(--leon-black)]/30 italic">No note — double-click to add one.</span>}
        </div>
      )}
      {!!(a.mentions || []).length && (
        <div className="text-[10px] text-[var(--leon-brown)] mt-1">
          Mentioned: {(a.mentions || []).map(id => personName(team, id)).join(', ')}
        </div>
      )}
      {!!(a.replies || []).length && (
        <div className="mt-2 space-y-1.5 border-l-2 border-[var(--leon-line)] pl-2">
          {a.replies.map(r => (
            <div key={r.id}>
              <div className="text-[10px] text-[var(--leon-black)]/45">{r.author} · {r.date}</div>
              <div className="text-[12px] whitespace-pre-wrap">{r.text}</div>
            </div>
          ))}
        </div>
      )}
      {editable && (
        <div className="mt-2 space-y-1.5">
          <div className="flex gap-1">
            <TextInput placeholder="Reply… (@ a colleague to notify them)" value={reply}
              onChange={e => setReply(e.target.value)}
              onKeyDown={e => { if (e.key === 'Enter' && reply.trim()) { onReply(a.id, reply.trim()); setReply(''); } }} />
            <Button size="sm" disabled={!reply.trim()} onClick={() => { onReply(a.id, reply.trim()); setReply(''); }}>Send</Button>
          </div>
          <div className="flex items-center gap-1 flex-wrap">
            <Select className="!w-auto !py-1 !text-xs" value={a.state || 'Open'}
              onChange={e => onUpdate(a.id, { state: e.target.value, resolved: e.target.value !== 'Open' })}>
              {PDF_COMMENT_STATES.map(s => <option key={s}>{s}</option>)}
            </Select>
            {a.state !== 'Open'
              ? <Button size="sm" variant="ghost" onClick={() => onUpdate(a.id, { state: 'Open', resolved: false })}>Reopen</Button>
              : <Button size="sm" variant="ghost" onClick={() => onUpdate(a.id, { state: 'Completed', resolved: true })}>Resolve</Button>}
            <Button size="sm" variant="ghost" className="ml-auto" onClick={() => onDelete(a.id)}>Delete</Button>
          </div>
        </div>
      )}
    </div>
  );
}

function OfficePdfViewCommentsPanel({ body, team, editable, selectedId, onSelect, onUpdate, onDelete, onReply, onJump, currentUserName }) {
  const [author, setAuthor] = useState('');
  const [type, setType] = useState('');
  const [state, setState] = useState('');
  const [page, setPage] = useState('');
  const all = body.annotations || [];
  const authors = [...new Set(all.map(a => a.author).filter(Boolean))];
  const rows = all.filter(a =>
    (!author || a.author === author) &&
    (!type || a.type === type) &&
    (!state || (a.state || 'Open') === state) &&
    (!page || String((a.page || 0) + 1) === String(page)));

  return (
    <div className="flex flex-col h-full min-h-0">
      <div className="p-2 border-b border-[var(--leon-line)] grid grid-cols-2 gap-1.5">
        <Select className="!py-1 !text-xs" value={author} onChange={e => setAuthor(e.target.value)}>
          <option value="">Every author</option>
          {authors.map(a => <option key={a}>{a}</option>)}
        </Select>
        <Select className="!py-1 !text-xs" value={type} onChange={e => setType(e.target.value)}>
          <option value="">Every type</option>
          {PDF_ANNOTATION_TYPES.map(t => <option key={t}>{t}</option>)}
        </Select>
        <Select className="!py-1 !text-xs" value={state} onChange={e => setState(e.target.value)}>
          <option value="">Every status</option>
          {PDF_COMMENT_STATES.map(s => <option key={s}>{s}</option>)}
        </Select>
        <TextInput className="!py-1 !text-xs" placeholder="Page" value={page} onChange={e => setPage(e.target.value)} />
      </div>
      <div className="flex-1 min-h-0 overflow-auto p-2 space-y-2">
        <div className="text-[11px] text-[var(--leon-black)]/45">
          {rows.length} of {all.length} markup{all.length === 1 ? '' : 's'} ·
          {' '}{all.filter(a => (a.state || 'Open') === 'Open').length} still open
        </div>
        {!rows.length ? <EmptyState text="Nothing marked up here yet." /> : rows.map(a => (
          <div key={a.id} onDoubleClick={() => onJump(a.page)}>
            <OfficePdfViewCommentCard a={a} team={team} editable={editable} selected={selectedId === a.id}
              onSelect={onSelect} onUpdate={onUpdate} onDelete={onDelete} onReply={onReply}
              currentUserName={currentUserName} />
          </div>
        ))}
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════════ right panel: Properties
// §105/§106. Two halves that must not be confused: what the Hub KNOWS about
// this document (page count, size, who touched it, when) is read-only and
// computed, and what someone DECIDES about it (category, LEON document number,
// subject, tags) is editable. A properties screen that lets you retype the page
// count is just a second, wrong copy of it.
function OfficePdfViewPropertiesPanel({ ctx, doc, body, plan, editable, assetInfo, indexState, onSet, onSetDoc }) {
  const meta = body.metadata || {};
  const bytes = assetInfo.bytes;
  const rows = [
    ['Name', doc.name],
    ['Project', doc.projectId ? ((ctx.projects || []).find(p => p.id === doc.projectId) || {}).name || '—' : 'Not linked'],
    ['Category', body.category || 'Other'],
    ['Pages', String(plan.length) + ((body.pages || []).length !== plan.length ? ` (${(body.pages || []).length - plan.length} hidden)` : '')],
    ['Source files', String(assetInfo.count)],
    ['Size on disk', officePdfBytesLabel(bytes)],
    ['Created', (doc.createdDate || '—') + (doc.createdBy ? ' · ' + doc.createdBy : '')],
    ['Modified', (doc.modifiedDate || '—') + (doc.modifiedBy ? ' · ' + doc.modifiedBy : '')],
    ['Revision', String(doc.revision || 0)],
    ['Status', doc.status || 'Draft'],
    ['Source', body.sourceType || 'Uploaded'],
    ['Text index', indexState.status],
    ['OCR', body.ocrStatus || 'Not Processed'],
    ['Signatures', body.signatureStatus || 'Unsigned'],
    ['Form', body.formStatus || 'None'],
    ['Security', body.security && (body.security.restrictEditing || body.security.restrictPrinting)
      ? 'LEON policy set (not PDF encryption)' : 'None'],
  ];
  return (
    <div className="p-3 space-y-4 overflow-auto h-full">
      <div>
        <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/45 mb-1.5">What the Hub knows</div>
        <table className="w-full text-[12px]">
          <tbody>
            {rows.map(([k, v]) => (
              <tr key={k} className="border-b border-[var(--leon-line)]/60">
                <td className="py-1 pr-2 text-[var(--leon-black)]/50 align-top w-[42%]">{k}</td>
                <td className="py-1 font-semibold break-words">{v || '—'}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <div className="space-y-2">
        <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/45">Metadata</div>
        <p className="text-[11px] text-[var(--leon-black)]/50 leading-snug">
          Title, author and keywords are the document record's own name, owner and tags — they are not kept
          twice. Editing them here edits the record, which is what an export writes into the PDF.
        </p>
        <Field label="Title (the document's name)">
          <TextInput value={doc.name || ''} disabled={!editable} onChange={e => onSetDoc({ name: e.target.value })} />
        </Field>
        <Field label="Subject">
          <TextInput value={meta.subject || ''} disabled={!editable}
            onChange={e => onSet({ metadata: { ...meta, subject: e.target.value } })} />
        </Field>
        <Field label="Author">
          <TextInput value={doc.owner || ''} disabled={!editable} onChange={e => onSetDoc({ owner: e.target.value })} />
        </Field>
        <Field label="Keywords / LEON tags" hint="Comma separated.">
          <TextInput value={(doc.tags || []).join(', ')} disabled={!editable}
            onChange={e => onSetDoc({ tags: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })} />
        </Field>
        <Field label="LEON document number" hint="LEON's own reference, not the PDF's.">
          <TextInput value={body.documentNumber || ''} disabled={!editable}
            onChange={e => onSet({ documentNumber: e.target.value })} />
        </Field>
        <Field label="Category">
          <Select value={body.category || 'Other'} disabled={!editable} onChange={e => onSet({ category: e.target.value })}>
            {PDF_CATEGORIES.map(c => <option key={c}>{c}</option>)}
          </Select>
        </Field>
        <Field label="Status">
          <Select value={doc.status || 'Draft'} disabled={!editable} onChange={e => onSetDoc({ status: e.target.value })}>
            {OFFICE_STATUSES.map(s => <option key={s}>{s}</option>)}
          </Select>
        </Field>
      </div>
    </div>
  );
}

// Reads the PDF's OWN form fields, if it has any. pdf.js reports widget
// annotations with their field name, type and rectangle, which is everything
// needed to make them LEON fields; going through the core's document handle
// keeps this file free of any direct library call.
function officePdfViewReadAcroForm(assetId, planPages) {
  return officePdfDocument(assetId).then(pdf => {
    const out = [];
    let chain = Promise.resolve();
    planPages.forEach(pp => {
      if (pp.assetId !== assetId) return;
      chain = chain.then(() => pdf.getPage(pp.index + 1).then(page => {
        const v = page.getViewport({ scale: 1, rotation: 0 });
        return page.getAnnotations().then(anns => {
          (anns || []).forEach(an => {
            if (an.subtype !== 'Widget' || !an.fieldName) return;
            const r = an.rect || [0, 0, 0, 0];
            const x0 = Math.min(r[0], r[2]), x1 = Math.max(r[0], r[2]);
            const y0 = Math.min(r[1], r[3]), y1 = Math.max(r[1], r[3]);
            const type = an.fieldType === 'Btn' ? (an.checkBox ? 'Checkbox' : an.radioButton ? 'Radio' : 'Checkbox')
              : an.fieldType === 'Ch' ? 'Dropdown'
              : an.multiLine ? 'Multiline' : 'Text';
            out.push({
              page: pp.planIndex, name: an.fieldName, type,
              rect: { x: x0 / v.width, y: (v.height - y1) / v.height, w: (x1 - x0) / v.width, h: (y1 - y0) / v.height },
              value: typeof an.fieldValue === 'string' ? an.fieldValue : '',
              options: (an.options || []).map(o => o.displayValue || o.exportValue || String(o)),
              required: !!an.required, readOnly: !!an.readOnly,
            });
          });
        });
      }));
    });
    return chain.then(() => out);
  });
}

// ═══════════════════════════════════════════════════════ right panel: Forms
function OfficePdfViewFormsPanel({
  ctx, doc, body, plan, editable, formMode, setFormMode, tool, setTool,
  selectedId, onSelect, onField, onAddFieldsFromAcroForm, onFillFromLeon, onReviewToLeon, onJump,
}) {
  const [acro, setAcro] = useState({ state: 'idle', found: 0 });
  const fields = body.fields || [];
  const selected = fields.find(f => f.id === selectedId) || null;
  const missing = fields.filter(f => f.required && (f.value === '' || f.value == null || f.value === false));

  function readAcro() {
    const assetIds = [...new Set(plan.map(p => p.assetId))];
    setAcro({ state: 'reading', found: 0 });
    Promise.all(assetIds.map(id => officePdfViewReadAcroForm(id, plan).catch(() => [])))
      .then(lists => {
        const flat = [].concat(...lists);
        setAcro({ state: 'done', found: flat.length });
        if (flat.length) onAddFieldsFromAcroForm(flat);
      })
      .catch(() => setAcro({ state: 'error', found: 0 }));
  }

  return (
    <div className="p-3 space-y-4 overflow-auto h-full">
      <div className="rounded-lg border border-[var(--leon-line)] p-2">
        <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/45 mb-1.5">Mode</div>
        {/* Designing a form and filling one in are different jobs with different
            mistakes attached, so they are separate modes rather than one screen
            where a stray drag moves a field someone is trying to type into. */}
        <div className="flex gap-1">
          <Button size="sm" variant={formMode === 'fill' ? 'primary' : 'outline'} onClick={() => { setFormMode('fill'); setTool('select'); }}>Fill</Button>
          <Button size="sm" variant={formMode === 'design' ? 'primary' : 'outline'} disabled={!editable} onClick={() => setFormMode('design')}>Design</Button>
        </div>
      </div>

      {formMode === 'design' && editable && (
        <div>
          <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/45 mb-1.5">Place a field</div>
          <div className="flex flex-wrap gap-1">
            {PDF_FIELD_TYPES.map(t => (
              <Button key={t} size="sm" variant={tool === 'field:' + t ? 'primary' : 'outline'}
                onClick={() => setTool(tool === 'field:' + t ? 'select' : 'field:' + t)}>{t}</Button>
            ))}
          </div>
          <p className="text-[11px] text-[var(--leon-black)]/50 mt-1.5">Drag a box on the page, or click once for a default size.</p>
        </div>
      )}

      <div>
        <div className="flex items-center gap-2 mb-1.5 flex-wrap">
          <span className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/45">Fields</span>
          <Badge tone={missing.length ? 'red' : 'green'}>{fields.length} placed · {missing.length} required still empty</Badge>
          {!!fields.length && (
            <button className="text-[11px] underline text-[var(--leon-brown)] ml-auto" onClick={readAcro}>
              Read the PDF's own fields
            </button>
          )}
        </div>
        {!!fields.length && acro.state === 'done' && !acro.found && (
          <div className="text-[11px] text-[var(--leon-black)]/55 mb-1.5">This PDF has no AcroForm of its own.</div>
        )}
        {!fields.length ? (
          <div className="space-y-2">
            <EmptyState text="No fields on this document yet." />
            <Button size="sm" variant="outline" onClick={readAcro}>Read the PDF's own form fields</Button>
            {acro.state === 'reading' && <div className="text-[11px]">Reading…</div>}
            {acro.state === 'done' && !acro.found && (
              <div className="text-[11px] text-[var(--leon-black)]/55">
                This PDF has no AcroForm — there are no fillable fields inside the file itself. Place LEON
                fields over it instead; they are stored on the LEON record, not written into the original.
              </div>
            )}
            {acro.state === 'error' && <div className="text-[11px] text-[var(--leon-red,#C0392B)]">The form could not be read.</div>}
          </div>
        ) : (
          <div className="space-y-1">
            {fields.map(f => (
              <button key={f.id} onClick={() => { onSelect(f.id); onJump(f.page); }}
                className={`w-full text-left rounded-md border px-2 py-1.5 text-[12px] ${selectedId === f.id ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)]'}`}>
                <div className="flex items-center gap-1.5">
                  <span className="font-semibold truncate">{f.name || '(unnamed)'}</span>
                  <span className="text-[10px] text-[var(--leon-black)]/40">{f.type}</span>
                  {f.required && <span className="text-[10px] text-[var(--leon-red,#C0392B)]">required</span>}
                  <span className="ml-auto text-[10px] text-[var(--leon-black)]/40">p{(f.page || 0) + 1}</span>
                </div>
                <div className="text-[11px] text-[var(--leon-black)]/55 truncate">
                  {f.type === 'Checkbox' ? (f.value ? 'ticked' : 'not ticked') : (f.value || <i>empty</i>)}
                  {f.leonSource ? ' · from ' + f.leonSource : ''}
                </div>
              </button>
            ))}
          </div>
        )}
      </div>

      {selected && editable && formMode === 'design' && (
        <div className="rounded-lg border border-[var(--leon-line)] p-2 space-y-2">
          <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/45">Selected field</div>
          <Field label="Name"><TextInput value={selected.name || ''} onChange={e => onField(selected.id, { name: e.target.value })} /></Field>
          <Field label="Label"><TextInput value={selected.label || ''} onChange={e => onField(selected.id, { label: e.target.value })} /></Field>
          <Field label="Type">
            <Select value={selected.type} onChange={e => onField(selected.id, { type: e.target.value })}>
              {PDF_FIELD_TYPES.map(t => <option key={t}>{t}</option>)}
            </Select>
          </Field>
          {(selected.type === 'Dropdown' || selected.type === 'Radio') && (
            <Field label="Options" hint="One per line.">
              <TextArea rows={3} value={(selected.options || []).join('\n')}
                onChange={e => onField(selected.id, { options: e.target.value.split('\n').map(s => s.trim()).filter(Boolean) })} />
            </Field>
          )}
          {(selected.type === 'Signature' || selected.type === 'Initial') && (
            <Field label="Signer role" hint="Prepared By, Reviewed By, Approved By…">
              <TextInput value={selected.signerRole || ''} onChange={e => onField(selected.id, { signerRole: e.target.value })} />
            </Field>
          )}
          <div className="flex items-center gap-3 text-[12px]">
            <label className="flex items-center gap-1"><input type="checkbox" checked={!!selected.required} onChange={e => onField(selected.id, { required: e.target.checked })} /> Required</label>
            <label className="flex items-center gap-1"><input type="checkbox" checked={!!selected.readOnly} onChange={e => onField(selected.id, { readOnly: e.target.checked })} /> Read only</label>
          </div>
          {/* §51 — the field points at a live Hub value. It is READ when someone
              asks for it and the answer is stored, so a filled form keeps saying
              what it said on the day it was filled. */}
          <Field label="Fill from LEON data" hint="Read on demand; the value is then stored on the field.">
            <Select value={selected.leonSource || ''} onChange={e => onField(selected.id, { leonSource: e.target.value || null })}>
              <option value="">— not linked —</option>
              {PDFVIEW_LEON_SOURCES.map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
            </Select>
          </Field>
          <Button size="sm" variant="ghost" onClick={() => onField(selected.id, null)}>Delete this field</Button>
        </div>
      )}

      <div className="rounded-lg border border-[var(--leon-line)] p-2 space-y-2">
        <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/45">LEON data</div>
        <Button size="sm" variant="outline" disabled={!editable || !fields.some(f => f.leonSource)} onClick={onFillFromLeon}>
          Fill the linked fields from LEON
        </Button>
        <p className="text-[11px] text-[var(--leon-black)]/50 leading-snug">
          §52 the other way round: sending what this form says back INTO a LEON record never happens on its
          own. Review it first — the reviewed result is filed against the document and written to the
          project's change log, and a person still makes the change in the module that owns the record.
        </p>
        <Button size="sm" variant="outline" disabled={!fields.length} onClick={onReviewToLeon}>
          Review this form as LEON data…
        </Button>
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════════ right panel: Signatures
// TWO DIFFERENT THINGS, and conflating them is the failure this panel exists to
// prevent:
//   • A LEON APPROVAL is an authenticated in-app act. The Hub knows who was
//     signed in, when, and which revision they approved, and records exactly
//     that. It is as strong as the Hub's own login and no stronger.
//   • A DIGITAL SIGNATURE is cryptographic: a certificate, a private key held
//     by the signer, and a hash of the file that any reader can verify without
//     trusting us. That needs a certificate authority and a server. This page
//     has neither, so the Hub does not offer it and never calls a drawn or
//     typed name one.
function OfficePdfViewSignaturesPanel({
  ctx, doc, body, editable, onSign, onRemoveSignature, onRequest, onPlaceField,
  onLock, onNewRevision, onJump,
}) {
  const [kind, setKind] = useState('LEON Approval');
  const [role, setRole] = useState('');
  const [typed, setTyped] = useState(ctx.currentUserName || '');
  const [drawn, setDrawn] = useState(null);
  const [uploaded, setUploaded] = useState(null);
  const [fieldId, setFieldId] = useState('');
  const [reqOpen, setReqOpen] = useState(false);
  const [reqIds, setReqIds] = useState([]);
  const [reqSeq, setReqSeq] = useState(false);
  const [reqMsg, setReqMsg] = useState('');
  const [reqDue, setReqDue] = useState('');

  const sigFields = (body.fields || []).filter(f => f.type === 'Signature' || f.type === 'Initial');
  const signatures = body.signatures || [];
  const requests = body.signatureRequests || [];
  const unsignedFields = sigFields.filter(f => !signatures.some(s => s.fieldId === f.id));
  const locked = !!body.locked;
  // Signing while looking through someone else's eyes would put THEIR name on
  // an approval the real signed-in person made. It is blocked rather than
  // silently mis-attributed.
  const viewingAs = !!ctx.isViewingAs;

  function readImage(e) {
    const f = e.target.files && e.target.files[0];
    if (!f) return;
    const r = new FileReader();
    r.onload = () => setUploaded(r.result);
    r.readAsDataURL(f);
  }

  return (
    <div className="p-3 space-y-4 overflow-auto h-full">
      <div className={`rounded-lg border p-2 ${locked ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)]'}`}>
        <div className="text-sm font-bold">{body.signatureStatus || 'Unsigned'}</div>
        <div className="text-[11px] text-[var(--leon-black)]/55 leading-snug">
          {locked
            ? 'This revision is locked. The signed document is never edited underneath a signature — to change anything, fork a new revision.'
            : `${signatures.length} signature${signatures.length === 1 ? '' : 's'} placed · ${unsignedFields.length} signature field${unsignedFields.length === 1 ? '' : 's'} still open.`}
        </div>
        {locked && editable && <Button size="sm" variant="outline" className="mt-2" onClick={onNewRevision}>Create a new revision to keep editing</Button>}
      </div>

      <div className="rounded-md border border-dashed border-[var(--leon-line)] px-3 py-2">
        <div className="text-[11px] text-[var(--leon-black)]/60 leading-snug">
          <b>No cryptographic signing here.</b> A drawn, typed or uploaded signature is a picture of one —
          it proves nothing on its own. A <b>LEON Approval</b> is stronger: it records the authenticated
          session, the moment and the revision. A verifiable digital signature needs a certificate and a
          server to issue and check it, and the Hub has neither.
        </div>
      </div>

      {!!sigFields.length && (
        <div>
          <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/45 mb-1.5">Signature fields</div>
          <div className="space-y-1">
            {sigFields.map((f, i) => {
              const s = signatures.find(x => x.fieldId === f.id);
              // A signer SEQUENCE means field n cannot be signed before n-1.
              const blockedBy = sigFields.slice(0, i).find(pf => !signatures.some(x => x.fieldId === pf.id));
              return (
                <div key={f.id} className="rounded-md border border-[var(--leon-line)] px-2 py-1.5 text-[12px]">
                  <div className="flex items-center gap-1.5">
                    <span className="font-semibold">{f.signerRole || f.name || 'Signature'}</span>
                    <span className="ml-auto text-[10px] text-[var(--leon-black)]/40">
                      <button className="underline" onClick={() => onJump(f.page)}>p{(f.page || 0) + 1}</button>
                    </span>
                  </div>
                  {s ? (
                    <div className="text-[11px] text-[var(--leon-black)]/60">
                      {s.kind} · {s.signerName} · {s.signedDate} {s.signedTime} · rev {s.revision}
                      {editable && !locked && <button className="ml-2 underline" onClick={() => onRemoveSignature(s.id)}>Remove</button>}
                    </div>
                  ) : (
                    <div className="text-[11px] text-[var(--leon-black)]/45">
                      Unsigned{blockedBy ? ` — waiting on ${blockedBy.signerRole || blockedBy.name || 'the field above'}` : ''}
                      {!blockedBy && editable && !locked && (
                        <button className="ml-2 underline text-[var(--leon-brown)]" onClick={() => setFieldId(f.id)}>Sign this one</button>
                      )}
                    </div>
                  )}
                </div>
              );
            })}
          </div>
        </div>
      )}

      {editable && !locked && (
        <div className="rounded-lg border border-[var(--leon-line)] p-2 space-y-2">
          <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/45">Sign</div>
          <Field label="Kind">
            <Select value={kind} onChange={e => setKind(e.target.value)}>
              {PDF_SIGNATURE_KINDS.map(k => <option key={k}>{k}</option>)}
            </Select>
          </Field>
          <Field label="Field">
            <Select value={fieldId} onChange={e => setFieldId(e.target.value)}>
              <option value="">— place it free-hand on the current page —</option>
              {unsignedFields.map(f => <option key={f.id} value={f.id}>{f.signerRole || f.name || 'Signature'} (p{(f.page || 0) + 1})</option>)}
            </Select>
          </Field>
          <Field label="Role on this document" hint="Prepared By / Reviewed By / Approved By.">
            <TextInput value={role} onChange={e => setRole(e.target.value)} placeholder="Approved By" />
          </Field>
          {kind === 'Typed Signature' && (
            <Field label="Type your name"><TextInput value={typed} onChange={e => setTyped(e.target.value)} /></Field>
          )}
          {kind === 'Drawn Signature' && <SignaturePad onChange={setDrawn} height={120} />}
          {kind === 'Uploaded Signature' && (
            <Field label="Signature image"><input type="file" accept="image/*" onChange={readImage} className="text-xs" /></Field>
          )}
          {kind === 'LEON Approval' && (
            <div className="text-[11px] text-[var(--leon-black)]/60 bg-[var(--leon-cream)] rounded px-2 py-1.5">
              Records <b>{ctx.currentUserName}</b>, today's date and time, and revision {doc.revision || 0}.
              {viewingAs && <div className="text-[var(--leon-red,#C0392B)] mt-1 font-semibold">You are viewing as someone else. Approvals are blocked until you stop.</div>}
            </div>
          )}
          <Button size="sm"
            disabled={(kind === 'LEON Approval' && viewingAs)
              || (kind === 'Drawn Signature' && !drawn)
              || (kind === 'Uploaded Signature' && !uploaded)
              || (kind === 'Typed Signature' && !typed.trim())}
            onClick={() => {
              onSign({ kind, fieldId: fieldId || null, role,
                       typedName: kind === 'Typed Signature' ? typed : '',
                       imageRef: kind === 'Drawn Signature' ? drawn : kind === 'Uploaded Signature' ? uploaded : null });
              setDrawn(null); setUploaded(null); setFieldId('');
            }}>
            {kind === 'LEON Approval' ? 'Approve as ' + (ctx.currentUserName || 'me') : 'Place the signature'}
          </Button>
        </div>
      )}

      {editable && !locked && (
        <div className="rounded-lg border border-[var(--leon-line)] p-2 space-y-2">
          <div className="flex items-center gap-2">
            <span className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/45">Request a signature</span>
            <Button size="sm" variant="ghost" className="ml-auto" onClick={() => setReqOpen(o => !o)}>{reqOpen ? 'Close' : 'New request'}</Button>
          </div>
          {reqOpen && (
            <div className="space-y-2">
              <Field label="Who">
                <div className="max-h-40 overflow-auto border border-[var(--leon-line)] rounded p-1.5 space-y-0.5">
                  {(ctx.teamDirectory || []).filter(p => p.active !== false).map(p => (
                    <label key={p.id} className="flex items-center gap-2 text-[12px]">
                      <input type="checkbox" checked={reqIds.includes(p.id)}
                        onChange={e => setReqIds(prev => e.target.checked ? [...prev, p.id] : prev.filter(x => x !== p.id))} />
                      {p.name}
                    </label>
                  ))}
                </div>
              </Field>
              <label className="flex items-center gap-2 text-[12px]">
                <input type="checkbox" checked={reqSeq} onChange={e => setReqSeq(e.target.checked)} />
                In this order, one after another
              </label>
              <Field label="Message"><TextArea rows={2} value={reqMsg} onChange={e => setReqMsg(e.target.value)} /></Field>
              <Field label="Due"><TextInput type="date" value={reqDue} onChange={e => setReqDue(e.target.value)} /></Field>
              <Button size="sm" disabled={!reqIds.length}
                onClick={() => { onRequest({ signerIds: reqIds, sequence: reqSeq ? reqIds : [], message: reqMsg, dueDate: reqDue || null });
                                 setReqOpen(false); setReqIds([]); setReqMsg(''); setReqDue(''); }}>
                Send the request
              </Button>
            </div>
          )}
          {!!requests.length && (
            <div className="space-y-1">
              {requests.map(r => (
                <div key={r.id} className="text-[11px] rounded border border-[var(--leon-line)] px-2 py-1">
                  <b>{r.status}</b> · {(r.signerIds || []).map(id => personName(ctx.teamDirectory, id)).join(', ')}
                  {r.dueDate ? ' · due ' + r.dueDate : ''}{r.sequence && r.sequence.length ? ' · in order' : ''}
                  <div className="text-[var(--leon-black)]/45">asked by {r.requestedBy} on {r.requestedDate}</div>
                </div>
              ))}
            </div>
          )}
        </div>
      )}

      {!!signatures.length && (
        <div>
          <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/45 mb-1.5">Signed</div>
          {signatures.map(s => (
            <div key={s.id} className="text-[11px] rounded border border-[var(--leon-line)] px-2 py-1 mb-1">
              <div className="flex items-center gap-1.5">
                <b>{s.signerName}</b>
                <Badge tone={s.kind === 'LEON Approval' ? 'green' : 'neutral'}>{s.kind}</Badge>
                <button className="ml-auto underline" onClick={() => onJump(s.page)}>p{(s.page || 0) + 1}</button>
              </div>
              <div className="text-[var(--leon-black)]/50">
                {s.role ? s.role + ' · ' : ''}{s.signedDate} {s.signedTime} · revision {s.revision}
              </div>
            </div>
          ))}
          {editable && !locked && (
            <Button size="sm" variant="outline" className="mt-1" onClick={onLock}>
              Lock this revision — signing is finished
            </Button>
          )}
        </div>
      )}

      {editable && !locked && (
        <Button size="sm" variant="ghost" onClick={onPlaceField}>
          Need a signature block? Place a Signature field from the Forms tab.
        </Button>
      )}
    </div>
  );
}

// ═══════════════════════════════════════════════ right panel: Document data
// §110 related documents and §111/§112 the version trail. The two kinds of
// history here are NOT the same thing and are shown apart on purpose:
//   • an autosave entry says the document was worked on — who, when;
//   • a REVISION is a decision, with a number, a reason and notes, and it is
//     what other people cite. Rolling one into the other would make "Rev 3"
//     mean whatever the last keystroke was.
function OfficePdfViewDataPanel({ ctx, doc, body, editable, onSetDoc, onSet, onNewRevision, onRestore, onOpenDoc }) {
  const projects = ctx.deptProjects(ctx.projects || []);
  const related = (ctx.officeDocs || []).filter(d => d.id !== doc.id && (
    (doc.projectId && d.projectId === doc.projectId) ||
    (body.documentNumber && d.body && d.body.documentNumber === body.documentNumber) ||
    (body.sourceDocId && d.id === body.sourceDocId) ||
    (d.body && d.body.sourceDocId === doc.id)));
  const versions = [...(doc.versions || [])].reverse();

  return (
    <div className="p-3 space-y-4 overflow-auto h-full">
      <div className="space-y-2">
        <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/45">Joined to</div>
        <Field label="Project">
          <Select value={doc.projectId || ''} disabled={!editable} onChange={e => onSetDoc({ projectId: e.target.value || null, scopeId: null })}>
            <option value="">— not linked —</option>
            {projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
          </Select>
        </Field>
        {doc.projectId && (() => {
          const p = (ctx.projects || []).find(x => x.id === doc.projectId);
          const scopes = p ? (p.scopes || []) : [];
          return (
            <Field label="Scope">
              <Select value={doc.scopeId || ''} disabled={!editable} onChange={e => onSetDoc({ scopeId: e.target.value || null })}>
                <option value="">— whole project —</option>
                {scopes.map(s => <option key={s.id} value={s.id}>{s.name || s.family}</option>)}
              </Select>
            </Field>
          );
        })()}
        <Field label="Client account">
          <Select value={doc.accountId || ''} disabled={!editable} onChange={e => onSetDoc({ accountId: e.target.value || null })}>
            <option value="">— not linked —</option>
            {(ctx.accounts || []).map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
          </Select>
        </Field>
        <Field label="Vendor">
          <Select value={doc.vendorId || ''} disabled={!editable} onChange={e => onSetDoc({ vendorId: e.target.value || null })}>
            <option value="">— not linked —</option>
            {(ctx.vendors || []).map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
          </Select>
        </Field>
        <Field label="Source" hint="Where this PDF came from.">
          <Select value={body.sourceType || 'Uploaded'} disabled={!editable} onChange={e => onSet({ sourceType: e.target.value })}>
            {PDF_SOURCE_TYPES.map(s => <option key={s}>{s}</option>)}
          </Select>
        </Field>
        {doc.projectId && ctx.goProject && (
          <Button size="sm" variant="outline" onClick={() => ctx.goProject(doc.projectId)}>Open the project</Button>
        )}
      </div>

      <div>
        <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/45 mb-1.5">Related documents</div>
        {!related.length ? <EmptyState text="Nothing else in the Hub points at this." /> : (
          <div className="space-y-1">
            {related.map(d => (
              <button key={d.id} onClick={() => onOpenDoc && onOpenDoc(d)}
                className="w-full text-left rounded-md border border-[var(--leon-line)] px-2 py-1.5 text-[12px] hover:bg-[var(--leon-cream)]">
                <div className="font-semibold truncate">{d.name}</div>
                <div className="text-[11px] text-[var(--leon-black)]/50">
                  {(OFFICE_APPS.find(a => a.key === d.app) || {}).label || d.app} · {d.status} · rev {d.revision || 0}
                  {d.body && d.body.documentNumber ? ' · ' + d.body.documentNumber : ''}
                </div>
              </button>
            ))}
          </div>
        )}
      </div>

      <div>
        <div className="flex items-center gap-2 mb-1.5">
          <span className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/45">History</span>
          {editable && <Button size="sm" variant="outline" className="ml-auto" onClick={onNewRevision}>Create a new revision</Button>}
        </div>
        <p className="text-[11px] text-[var(--leon-black)]/50 leading-snug mb-2">
          An <b>autosave</b> entry records that work happened. A <b>revision</b> is a decision with a number
          and a reason — it is what a submittal cites and what a signature is bound to.
        </p>
        {!versions.length ? <EmptyState text="No history yet." /> : (
          <div className="space-y-1">
            {versions.map((v, i) => (
              <div key={i} className={`rounded-md border px-2 py-1.5 text-[12px] ${v.formal ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)]'}`}>
                <div className="flex items-center gap-1.5">
                  <b>{v.formal ? 'Revision ' + v.n : 'Autosave'}</b>
                  <span className="ml-auto text-[10px] text-[var(--leon-black)]/40">{v.date} · {v.by}</span>
                </div>
                {v.note && <div className="text-[11px] text-[var(--leon-black)]/60 whitespace-pre-wrap">{v.note}</div>}
                {v.body && editable && (
                  <button className="text-[11px] underline text-[var(--leon-brown)] mt-0.5"
                    onClick={() => onRestore(v)}>Restore this revision's markup and pages</button>
                )}
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════════════ measurement panel
// Deliberately basic, and it says so where someone can read it before trusting
// a number. Calibration is per document and is recorded ON each measurement, so
// a figure taken at one scale is still readable after the calibration changes.
function OfficePdfViewMeasurePanel({ body, calibration, setCalibration, tool, setTool, editable, measurements, onDelete, onJump, onBeginCalibrate, ctx, doc }) {
  const [known, setKnown] = useState('');
  const [unit, setUnit] = useState(calibration ? calibration.unit : 'mm');
  const drawing = (body.category === 'Drawing') || /drawing|plan|elevation|section/i.test(doc.name || '');

  return (
    <div className="p-3 space-y-3 overflow-auto h-full">
      <div className="rounded-md border border-dashed border-[var(--leon-line)] px-3 py-2 text-[11px] leading-snug text-[var(--leon-black)]/65">
        <b>Basic measurement only.</b> This measures what is on the page against a length you calibrate by
        hand. A scaled construction take-off — sheet scale, revision impact, quantities that belong to a
        scope and feed a quote — is <b>Drawing Intelligence</b>'s job, and a second, unlinked set of
        quantities here is exactly how two different numbers for the same wall get quoted.
      </div>
      {/* There is no navigator that opens ONE software directly: LeonSoftwaresView
          holds which tool is open in its own local state and exposes no ctx entry
          point for it. So this goes to the LEON Softwares hub, one click from
          LEON Take-off, and says that rather than pretending to deep-link. */}
      <Button size="sm" variant="outline" disabled={typeof ctx.goSoftwares !== 'function'}
        onClick={() => ctx.goSoftwares && ctx.goSoftwares()}>
        Open LEON Softwares → Take-off
      </Button>
      {typeof ctx.goSoftwares !== 'function' && (
        <div className="text-[11px] text-[var(--leon-black)]/45">No navigation entry point is available from here.</div>
      )}
      {!drawing && (
        <div className="text-[11px] text-[var(--leon-black)]/45">
          This document is not filed as a Drawing, so take-off is probably not what you want here.
        </div>
      )}

      <div className="rounded-lg border border-[var(--leon-line)] p-2 space-y-2">
        <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/45">Calibration</div>
        {calibration ? (
          <div className="text-[12px]">
            1 page-width = <b>{(calibration.perFraction).toFixed(2)} {calibration.unit}</b>
            <div className="text-[11px] text-[var(--leon-black)]/50">
              set from a line of {calibration.known} {calibration.unit} on page {calibration.page + 1}
            </div>
            {editable && <Button size="sm" variant="ghost" onClick={() => setCalibration(null)}>Clear</Button>}
          </div>
        ) : (
          <div className="space-y-2">
            <p className="text-[11px] text-[var(--leon-black)]/55">
              Draw a line over something whose real length you know, then type that length. Until then a
              measurement is reported as a fraction of the page, which is honest but not useful.
            </p>
            <div className="flex gap-1">
              <TextInput placeholder="Known length" value={known} onChange={e => setKnown(e.target.value)} />
              <Select className="!w-24" value={unit} onChange={e => setUnit(e.target.value)}>
                {PDFVIEW_MEASURE_UNITS.map(u => <option key={u}>{u}</option>)}
              </Select>
            </div>
            <Button size="sm" variant={tool === 'measure:calibrate' ? 'primary' : 'outline'}
              disabled={!editable || !(parseFloat(known) > 0)}
              onClick={() => { if (tool === 'measure:calibrate') { setTool('select'); return; }
                               onBeginCalibrate(parseFloat(known), unit); }}>
              {tool === 'measure:calibrate' ? 'Now draw that line…' : 'Draw the known line'}
            </Button>
          </div>
        )}
      </div>

      <div className="flex gap-1">
        {PDFVIEW_MEASURE.map(m => (
          <Button key={m.key} size="sm" variant={tool === 'measure:' + m.key ? 'primary' : 'outline'} disabled={!editable}
            onClick={() => setTool(tool === 'measure:' + m.key ? 'select' : 'measure:' + m.key)}>
            {m.icon} {m.label}
          </Button>
        ))}
      </div>

      {!measurements.length ? <EmptyState text="Nothing measured yet." /> : (
        <div className="space-y-1">
          {measurements.map(m => (
            <div key={m.id} className="rounded-md border border-[var(--leon-line)] px-2 py-1.5 text-[12px]">
              <div className="flex items-center gap-1.5">
                <b>{m.text}</b>
                <span className="text-[10px] text-[var(--leon-black)]/40">{m.measureKind}</span>
                <button className="ml-auto underline text-[10px]" onClick={() => onJump(m.page)}>p{(m.page || 0) + 1}</button>
                {editable && <button className="underline text-[10px]" onClick={() => onDelete(m.id)}>Delete</button>}
              </div>
              <div className="text-[10px] text-[var(--leon-black)]/45">
                {m.calibrated ? 'calibrated ' + m.calibratedAs : 'no calibration — page fractions'} · {m.author} · {m.date}
              </div>
            </div>
          ))}
        </div>
      )}
      {/* Calibration and the measurement figures are kept where they were taken,
          and are never pushed into a scope quantity. Nothing here writes to a
          project record. */}
      <p className="text-[10px] text-[var(--leon-black)]/40">
        Measurements stay on this document. They are not written into any scope quantity or take-off.
      </p>
    </div>
  );
}

// Geometry for a measurement. Percentages are of the page's OWN width and
// height, which are different lengths, so y is converted into width-units by
// the page's aspect ratio before anything is added up. Skipping that makes
// every diagonal wrong on any page that is not square.
function officePdfViewMeasureRaw(points, kind, aspect) {
  const p = (points || []).map(q => ({ x: q.x, y: q.y * aspect }));
  if (p.length < 2) return 0;
  if (kind === 'area') {
    let s = 0;
    for (let i = 0; i < p.length; i++) {
      const a = p[i], b = p[(i + 1) % p.length];
      s += a.x * b.y - b.x * a.y;
    }
    return Math.abs(s / 2);
  }
  let d = 0;
  for (let i = 1; i < p.length; i++) d += Math.hypot(p[i].x - p[i - 1].x, p[i].y - p[i - 1].y);
  if (kind === 'perimeter' && p.length > 2) d += Math.hypot(p[0].x - p[p.length - 1].x, p[0].y - p[p.length - 1].y);
  return d;
}

// ═══════════════════════════════════════════════════════════════ stamps
// A stamp records WHO put it there and WHEN, on the stamp itself, because a
// document that says APPROVED without saying by whom is worse than one that
// says nothing. Nothing here ever places an approval stamp on its own: no
// inference, no rule, no "the submittal was approved so stamp it" — a person
// picks the stamp and a person is named on it.
function OfficePdfViewStampPanel({ ctx, doc, editable, tool, setTool, stampText, setStampText }) {
  const project = doc.projectId ? (ctx.projects || []).find(p => p.id === doc.projectId) : null;
  const co = ctx.companyProfile || {};
  const custom = [
    co.legalName || co.name || 'LEON',
    project ? (project.name || '').toUpperCase() : null,
    'RECEIVED — ' + (typeof todayISO === 'function' ? todayISO() : ''),
  ].filter(Boolean);
  return (
    <div className="space-y-2">
      <div className="text-[11px] text-[var(--leon-black)]/55 leading-snug">
        Pick a stamp, then click the page. Your name and today's date are recorded on it. Nothing places an
        approval stamp for you — approval is a decision, not something the Hub can infer.
      </div>
      <div className="flex flex-wrap gap-1">
        {PDF_STAMP_LIBRARY.map(s => (
          <Button key={s} size="sm" variant={tool === 'Stamp' && stampText === s ? 'primary' : 'outline'} disabled={!editable}
            onClick={() => { setStampText(s); setTool('Stamp'); }}>{s}</Button>
        ))}
      </div>
      <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/40 pt-1">Company & project</div>
      <div className="flex flex-wrap gap-1">
        {custom.map(s => (
          <Button key={s} size="sm" variant={tool === 'Stamp' && stampText === s ? 'primary' : 'outline'} disabled={!editable}
            onClick={() => { setStampText(s); setTool('Stamp'); }}>{s}</Button>
        ))}
      </div>
      <Field label="Or type one">
        <div className="flex gap-1">
          <TextInput value={stampText} onChange={e => setStampText(e.target.value)} />
          <Button size="sm" disabled={!editable || !stampText.trim()} onClick={() => setTool('Stamp')}>Use</Button>
        </div>
      </Field>
    </div>
  );
}

// ═══════════════════════════════════════════════════════ superseded (§109)
function OfficePdfViewSupersededBanner({ ctx, doc, body, onOpenDoc }) {
  const successor = (ctx.officeDocs || []).find(d =>
    d.id !== doc.id && d.body && body.documentNumber &&
    d.body.documentNumber === body.documentNumber && (d.revision || 0) > (doc.revision || 0));
  if (doc.status !== 'Superseded' && !successor) return null;
  return (
    <div className="rounded-lg border-2 border-[var(--leon-red,#C0392B)] bg-[#fdecea] px-4 py-2.5 flex items-center gap-3 flex-wrap">
      <span className="text-xl">⚠️</span>
      <div className="text-sm">
        <b>This is not the current document.</b>{' '}
        {doc.status === 'Superseded' ? 'It is marked Superseded. ' : ''}
        {successor ? `Revision ${successor.revision} of ${body.documentNumber} exists.` : 'Check for a newer revision before using it.'}
      </div>
      {successor && onOpenDoc && (
        <Button size="sm" variant="outline" className="ml-auto" onClick={() => onOpenDoc(successor)}>Open revision {successor.revision}</Button>
      )}
    </div>
  );
}

// ═════════════════════════════════════════════════════════════════════════
// LEON PDF — the editor
// ═════════════════════════════════════════════════════════════════════════
function OfficePdfEditor({ ctx, doc, onChange, editable }) {
  const body = (doc && doc.body) || makePdfBody();
  const canEdit = editable !== false && !body.locked;
  const team = ctx.teamDirectory || [];

  // ── view state ──────────────────────────────────────────────────────────
  const [leftTab, setLeftTab] = useState('pages');
  const [rightTab, setRightTab] = useState('comments');
  const [leftOpen, setLeftOpen] = useState(true);
  const [rightOpen, setRightOpen] = useState(true);
  const [mode, setMode] = useState('basic');
  const [group, setGroup] = useState('select');       // which header-toolbar group is open
  const [tool, setTool] = useState('select');
  const [toolOpts, setToolOpts] = useState({ color: '#F5C518', opacity: 0.4, weight: 2 });
  const [stampText, setStampText] = useState(PDF_STAMP_LIBRARY[0]);
  const [layout, setLayout] = useState('continuous');
  const [zoomMode, setZoomMode] = useState('fitWidth');
  const [zoomValue, setZoomValue] = useState(1);
  const [fullScreen, setFullScreen] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  const [current, setCurrent] = useState(0);
  const [jumpTo, setJumpTo] = useState(null);
  const [pageSel, setPageSel] = useState([]);
  const [formMode, setFormMode] = useState('fill');
  const [live, setLive] = useState(null);            // an in-progress drag, not yet committed
  const [busy, setBusy] = useState(null);
  const [toolsTool, setToolsTool] = useState(null);

  // search, lifted here because the PAGES need the hits as much as the panel does
  const [query, setQuery] = useState('');
  const [caseSensitive, setCaseSensitive] = useState(false);
  const [wholeWord, setWholeWord] = useState(false);
  const [flashHit, setFlashHit] = useState(null);

  // modals
  const [cropDraft, setCropDraft] = useState(null);
  const [pageProps, setPageProps] = useState(null);
  const [revModal, setRevModal] = useState(null);
  const [reviewModal, setReviewModal] = useState(null);
  const [commentModal, setCommentModal] = useState(null);
  const [pendingCalib, setPendingCalib] = useState(null);

  const undoRef = useRef([]);
  const [undoDepth, setUndoDepth] = useState(0);

  const plan = useMemo(() => officePdfViewVisiblePages(body), [body.pages]);

  // ── page sizes ──────────────────────────────────────────────────────────
  // Fetched once per page and merged in ONE setState. Three hundred separate
  // state updates is three hundred renders of a three-hundred-page list.
  const [sizes, setSizes] = useState({});
  useEffect(() => {
    let live2 = true;
    const missing = plan.filter(p => !sizes[p.assetId + ':' + p.index]);
    if (!missing.length) return undefined;
    Promise.all(missing.map(p => officePdfViewSize(p.assetId, p.index).then(s => [p.assetId + ':' + p.index, s])))
      .then(pairs => {
        if (!live2) return;
        setSizes(prev => { const next = { ...prev }; pairs.forEach(([k, v]) => { next[k] = v; }); return next; });
      });
    return () => { live2 = false; };
  }, [plan]);

  const [assetInfo, setAssetInfo] = useState({ count: 0, bytes: 0 });
  useEffect(() => {
    const ids = [...new Set(plan.map(p => p.assetId))];
    Promise.all(ids.map(id => officePdfGetAsset(id).catch(() => null)))
      .then(list => setAssetInfo({ count: ids.length, bytes: list.reduce((s, r) => s + ((r && r.size) || 0), 0) }));
  }, [plan.length, (plan[0] || {}).assetId]);

  // ── text index ──────────────────────────────────────────────────────────
  const [assetText, setAssetText] = useState({});
  const [indexState, setIndexState] = useState({ status: body.indexStatus || 'Not Processed', done: 0, total: 0, scanned: false });

  function buildIndex() {
    const ids = [...new Set(plan.map(p => p.assetId))].filter(Boolean);
    if (!ids.length) return;
    setIndexState({ status: 'Processing', done: 0, total: 0, scanned: false });
    let done = 0;
    Promise.all(ids.map(id => officePdfViewAssetText(id, () => { done++; setIndexState(s => ({ ...s, done })); })
      .then(pages => [id, pages]).catch(() => [id, []])))
      .then(pairs => {
        const map = {}; pairs.forEach(([id, pages]) => { map[id] = pages; });
        setAssetText(map);
        const flat = [].concat(...pairs.map(([, p]) => p));
        const scanned = officePdfIsScanned(flat);
        setIndexState({ status: 'Complete', done: flat.length, total: flat.length, scanned });
        commit(b => {
          b.indexStatus = 'Complete';
          b.textIndexKey = ids[0];
          if (scanned && (b.ocrStatus === 'Not Processed' || !b.ocrStatus)) b.ocrStatus = 'Not Processed';
        }, null);
      })
      .catch(() => setIndexState(s => ({ ...s, status: 'Failed' })));
  }
  // A previously built index is picked up silently on open — it lives in
  // IndexedDB against the ASSET, so it survives reloads and is shared by every
  // document that includes the same file.
  useEffect(() => {
    const ids = [...new Set(plan.map(p => p.assetId))].filter(Boolean);
    if (!ids.length || indexState.status === 'Processing') return;
    Promise.all(ids.map(id => officePdfGetText(id).then(p => [id, p]).catch(() => [id, null])))
      .then(pairs => {
        if (pairs.some(([, p]) => !p || !p.length)) return;
        const map = {}; pairs.forEach(([id, p]) => { map[id] = p; });
        setAssetText(map);
        const flat = [].concat(...pairs.map(([, p]) => p));
        setIndexState({ status: 'Complete', done: flat.length, total: flat.length, scanned: officePdfIsScanned(flat) });
      });
  }, [plan.length]);

  // The document-level index: plan position → that page's text. Built from the
  // per-asset caches so a page that appears twice is indexed twice, correctly.
  const docIndex = useMemo(() => {
    if (indexState.status !== 'Complete') return null;
    return plan.map(p => {
      const pages = assetText[p.assetId];
      const t = pages && pages[p.index];
      return t ? { ...t, page: p.planIndex } : { page: p.planIndex, text: '', items: [] };
    });
  }, [plan, assetText, indexState.status]);

  const textByPlan = useMemo(() => {
    const m = {};
    (docIndex || []).forEach(t => { m[t.page] = t; });
    return m;
  }, [docIndex]);

  const hits = useMemo(() => {
    if (!docIndex || !query.trim()) return [];
    return officePdfSearch(docIndex, query, { caseSensitive, wholeWord }).map(h => ({ ...h, key: h.page + ':' + h.index }));
  }, [docIndex, query, caseSensitive, wholeWord]);
  const hitsByPage = useMemo(() => {
    const m = {};
    hits.forEach(h => { (m[h.page] = m[h.page] || []).push(h); });
    return m;
  }, [hits]);

  const suggestion = useMemo(() => (docIndex ? officePdfViewSuggestCategory(docIndex) : null), [docIndex]);

  // ── writing ─────────────────────────────────────────────────────────────
  // ONE mutator. Everything that changes the document goes through it, so the
  // undo stack, the modified stamp and the autosave trail cannot be forgotten
  // by a new feature added later.
  function commit(fn, logText, docPatch) {
    const next = cloneDeep(body);
    // Records written before pages carried an id anchor still say only "page 4".
    // That number is correct RIGHT NOW, so it is turned into an anchor before
    // the change is applied — after which reordering keeps working for them too.
    officePdfViewBackfillAnchors(next);
    fn(next);
    undoRef.current = [...undoRef.current.slice(-(PDFVIEW_UNDO_LIMIT - 1)), cloneDeep(body)];
    setUndoDepth(undoRef.current.length);
    const versions = officePdfViewAutosave(doc, ctx.currentUserName);
    onChange({ ...doc, ...(docPatch || {}), body: next, versions,
               modifiedBy: ctx.currentUserName, modifiedDate: todayISO() });
    if (logText) logToProject(logText);
  }
  function setDocFields(patch, logText) {
    onChange({ ...doc, ...patch, modifiedBy: ctx.currentUserName, modifiedDate: todayISO() });
    if (logText) logToProject(logText);
  }
  function undo() {
    const stack = undoRef.current;
    if (!stack.length) return;
    const prev = stack[stack.length - 1];
    undoRef.current = stack.slice(0, -1);
    setUndoDepth(undoRef.current.length);
    onChange({ ...doc, body: prev, modifiedBy: ctx.currentUserName, modifiedDate: todayISO() });
  }
  // Change-log lines land on the PROJECT, which is where the rest of the Hub
  // records what happened. A document with no project keeps its own trail only.
  function logToProject(text) {
    if (!doc.projectId || typeof ctx.updateProject !== 'function' || typeof ctx.logAction !== 'function') return;
    ctx.updateProject(doc.projectId, draft => { ctx.logAction(draft, `LEON PDF — "${doc.name}": ${text}`); });
  }
  // ctx.notify is the app's ONE notification entry point and it is checked for
  // rather than assumed, because a softwares module can be loaded into a build
  // where it is not on ctx.
  function notifyPeople(eventKey, ids, title, bodyText) {
    if (typeof ctx.notify !== 'function' || !ids || !ids.length) return;
    ctx.notify(eventKey, { toUserIds: ids, title, body: bodyText, projectId: doc.projectId || null });
  }

  // ── markup ──────────────────────────────────────────────────────────────
  function createOnPage(planIndex, shape) {
    if (!canEdit) return;
    // Everything placed on a page is anchored to that page's ID as well as its
    // number. The number is what the renderer uses; the id is what survives a
    // reorder, an insert or a deletion, and officePdfViewReanchor recomputes
    // the number from it after every structural change.
    const pageId = (body.pages[planIndex] || {}).id || null;
    if (shape.__redaction) {
      commit(b => {
        b.redactions = [...(b.redactions || []),
          makePdfRedaction({ page: planIndex, pageId, rect: shape.rect,
                             matchedText: officePdfViewQuoteIn(textByPlan[planIndex], shape.rect, body.pages[planIndex]) },
                           ctx.currentUserName)];
      }, `marked an area on page ${planIndex + 1} for redaction`);
      setTool('select');
      return;
    }
    if (shape.__field) {
      commit(b => {
        b.fields = [...(b.fields || []),
          makePdfField({ page: planIndex, pageId, type: shape.__field, rect: shape.rect,
                         name: shape.__field.toLowerCase() + '_' + ((b.fields || []).length + 1) })];
        b.formStatus = 'Fields Placed';
      }, `placed a ${shape.__field} field on page ${planIndex + 1}`);
      setTool('select');
      return;
    }
    if (shape.__measure) {
      const pp = body.pages[planIndex] || {};
      const nat = sizes[pp.assetId + ':' + pp.index] || PDFVIEW_DEFAULT_PAGE;
      const aspect = nat.height / nat.width;
      const raw = officePdfViewMeasureRaw(shape.points, shape.__measure === 'calibrate' ? 'distance' : shape.__measure, aspect);
      if (shape.__measure === 'calibrate') {
        if (!pendingCalib || !(raw > 0)) { setTool('select'); return; }
        const cal = { known: pendingCalib.known, unit: pendingCalib.unit, page: planIndex,
                      perFraction: pendingCalib.known / raw };
        commit(b => { b.metadata = { ...(b.metadata || {}), calibration: cal }; },
          `calibrated measurement at ${pendingCalib.known} ${pendingCalib.unit} on page ${planIndex + 1}`);
        setPendingCalib(null); setTool('select');
        return;
      }
      const cal = (body.metadata || {}).calibration || null;
      const isArea = shape.__measure === 'area';
      const value = cal ? raw * (isArea ? cal.perFraction * cal.perFraction : cal.perFraction) : raw;
      const label = cal ? value.toFixed(2) + ' ' + cal.unit + (isArea ? '²' : '')
        : value.toFixed(4) + ' page' + (isArea ? '²' : ' widths');
      commit(b => {
        b.annotations = [...(b.annotations || []),
          makePdfAnnotation({ page: planIndex, pageId, type: 'Measurement', measureKind: shape.__measure,
                              points: shape.points, rect: officePdfViewPointsBounds(shape.points),
                              color: '#C0392B', opacity: 1, weight: 2, text: label,
                              calibrated: !!cal, calibratedAs: cal ? `${cal.perFraction.toFixed(2)} ${cal.unit} per page width` : '',
                              authorId: ctx.currentUserId }, ctx.currentUserName)];
      }, `measured ${shape.__measure} on page ${planIndex + 1}: ${label}`);
      return;
    }
    const extra = {};
    if (shape.type === 'Stamp') {
      // The stamp carries its own provenance. Reading who stamped it should not
      // require opening a panel.
      extra.text = stampText;
      extra.subtext = `${ctx.currentUserName} · ${todayISO()}`;
      extra.color = /VOID|SUPERSEDED|REVISE/.test(stampText) ? '#C0392B' : '#1F7A3D';
    }
    commit(b => {
      b.annotations = [...(b.annotations || []),
        makePdfAnnotation({ page: planIndex, pageId, ...shape, ...extra, authorId: ctx.currentUserId,
                            mentions: officePdfViewMentions(shape.text, team) }, ctx.currentUserName)];
    }, `added a ${shape.type} on page ${planIndex + 1}`);
    if (shape.type === 'Text Comment' || shape.type === 'Sticky Note' || shape.type === 'Text Box' || shape.type === 'Callout') {
      // Placed empty, then opened — typing the note is the point of placing it.
      setTimeout(() => setCommentModal({ pending: true, planIndex }), 0);
    }
  }
  function updateAnnotation(id, patch, preview) {
    if (!canEdit) return;
    if (preview) { setLive({ id, patch }); return; }
    const merged = live && live.id === id ? { ...live.patch, ...patch } : patch;
    setLive(null);
    if (!Object.keys(merged).length) return;
    commit(b => {
      b.annotations = (b.annotations || []).map(a => a.id === id
        ? { ...a, ...merged, mentions: merged.text !== undefined ? officePdfViewMentions(merged.text, team) : a.mentions }
        : a);
    }, null);
    if (merged.text) {
      const ids = officePdfViewMentions(merged.text, team).filter(x => x !== ctx.currentUserId);
      notifyPeople('mention', ids, `Mentioned on "${doc.name}"`, merged.text.slice(0, 200));
    }
  }
  function deleteAnnotation(id) {
    if (!canEdit) return;
    const a = (body.annotations || []).find(x => x.id === id);
    commit(b => { b.annotations = (b.annotations || []).filter(x => x.id !== id); },
      a ? `deleted a ${a.type} from page ${(a.page || 0) + 1}` : null);
    setSelectedId(null);
  }
  function replyToAnnotation(id, text) {
    if (!canEdit) return;
    commit(b => {
      b.annotations = (b.annotations || []).map(a => a.id === id ? {
        ...a, replies: [...(a.replies || []),
          { id: uid('rep'), author: ctx.currentUserName, authorId: ctx.currentUserId, date: todayISO(), text }],
      } : a);
    }, null);
    const ids = officePdfViewMentions(text, team).filter(x => x !== ctx.currentUserId);
    const a = (body.annotations || []).find(x => x.id === id);
    if (a && a.authorId && a.authorId !== ctx.currentUserId) ids.push(a.authorId);
    notifyPeople('mention', [...new Set(ids)], `Reply on "${doc.name}"`, text.slice(0, 200));
  }

  // ── pages: all of these edit the ORDER PLAN, never the bytes ────────────
  function rotatePages(indexes, deg) {
    commit(b => {
      b.pages = b.pages.map((p, i) => indexes.includes(i) ? { ...p, rotate: (((p.rotate || 0) + deg) % 360 + 360) % 360 } : p);
    }, `rotated ${indexes.length} page${indexes.length === 1 ? '' : 's'} by ${deg}°`);
  }
  function deletePages(indexes) {
    commit(b => {
      b.pages = b.pages.filter((p, i) => !indexes.includes(i));
      b.pageCount = b.pages.length;
      officePdfViewReanchor(b);
    }, `removed ${indexes.length} page${indexes.length === 1 ? '' : 's'} from the order`);
    setPageSel([]);
  }
  function duplicatePages(indexes) {
    commit(b => {
      const add = indexes.map(i => b.pages[i]).filter(Boolean)
        .map(p => makePdfPage(p.assetId, p.index, { rotate: p.rotate, crop: p.crop, label: p.label }));
      b.pages = [...b.pages, ...add];
      b.pageCount = b.pages.length;
      officePdfViewReanchor(b);
    }, `duplicated ${indexes.length} page${indexes.length === 1 ? '' : 's'}`);
  }
  function reorderPages(moving, target) {
    commit(b => {
      const moved = moving.map(i => b.pages[i]).filter(Boolean);
      const rest = b.pages.filter((p, i) => !moving.includes(i));
      const tgt = b.pages[target];
      let at = rest.indexOf(tgt);
      if (at < 0) at = rest.length;
      b.pages = [...rest.slice(0, at), ...moved, ...rest.slice(at)];
      officePdfViewReanchor(b);
    }, `reordered ${moving.length} page${moving.length === 1 ? '' : 's'}`);
    setPageSel([]);
  }
  function insertPdf(at, file, where) {
    setBusy('Reading ' + file.name + '…');
    officePdfImportFile(file).then(res => {
      setBusy(null);
      commit(b => {
        const add = [];
        for (let i = 0; i < res.pageCount; i++) add.push(makePdfPage(res.assetId, i));
        const idx = where === 'before' ? at : at + 1;
        b.pages = [...b.pages.slice(0, idx), ...add, ...b.pages.slice(idx)];
        b.pageCount = b.pages.length;
        if (!b.originalAssetId) b.originalAssetId = res.assetId;
        officePdfViewReanchor(b);
      }, `inserted ${res.pageCount} page${res.pageCount === 1 ? '' : 's'} from ${file.name}`);
    }).catch(err => setBusy('Could not read that file: ' + (err && err.message)));
  }
  function replacePage(at, file) {
    setBusy('Reading ' + file.name + '…');
    officePdfImportFile(file).then(res => {
      setBusy(null);
      commit(b => {
        const add = [];
        for (let i = 0; i < res.pageCount; i++) add.push(makePdfPage(res.assetId, i));
        b.pages = [...b.pages.slice(0, at), ...add, ...b.pages.slice(at + 1)];
        b.pageCount = b.pages.length;
        officePdfViewReanchor(b);
      }, `replaced page ${at + 1} with ${res.pageCount} page${res.pageCount === 1 ? '' : 's'} from ${file.name}`);
    }).catch(err => setBusy('Could not read that file: ' + (err && err.message)));
  }
  // Extract writes a NEW file and hands it to the browser. It is the one page
  // operation that produces bytes, and it still does not touch the source.
  function extractPages(indexes) {
    const chosen = indexes.map(i => body.pages[i]).filter(Boolean);
    if (!chosen.length) return;
    setBusy('Building the extract…');
    officePdfAssemble(chosen.map(p => ({ assetId: p.assetId, index: p.index, rotate: p.rotate, crop: p.crop })),
      { title: doc.name + ' (extract)', author: ctx.currentUserName })
      .then(out => officePdfSaveDoc(out))
      .then(bytes => {
        officePdfDownload(bytes, safeFileName(doc.name + '-pages-' + indexes.map(i => i + 1).join('_')) + '.pdf');
        setBusy(null);
        logToProject(`extracted ${chosen.length} page${chosen.length === 1 ? '' : 's'} as a separate PDF`);
      })
      .catch(err => setBusy('The extract failed: ' + (err && err.message)));
  }
  function applyCrop() {
    if (!cropDraft) return;
    commit(b => {
      b.pages = b.pages.map((p, i) => i === cropDraft.planIndex ? { ...p, crop: cropDraft.rect } : p);
    }, `cropped page ${cropDraft.planIndex + 1}`);
    setCropDraft(null); setTool('select');
  }

  // ── bookmarks ───────────────────────────────────────────────────────────
  function addBookmark(parentId) {
    commit(b => {
      const bm = makePdfBookmark({ title: 'Page ' + (current + 1), page: current });
      if (!parentId) { b.bookmarks = [...(b.bookmarks || []), bm]; return; }
      const walk = (list) => list.map(x => x.id === parentId
        ? { ...x, children: [...(x.children || []), bm] }
        : { ...x, children: walk(x.children || []) });
      b.bookmarks = walk(b.bookmarks || []);
    }, `added a bookmark at page ${current + 1}`);
  }
  function editBookmarks(fn, log) { commit(b => { b.bookmarks = fn(b.bookmarks || []); }, log); }
  function renameBookmark(id, title) {
    editBookmarks(list => { const w = l => l.map(x => x.id === id ? { ...x, title } : { ...x, children: w(x.children || []) }); return w(list); }, null);
  }
  function deleteBookmark(id) {
    editBookmarks(list => { const w = l => l.filter(x => x.id !== id).map(x => ({ ...x, children: w(x.children || []) })); return w(list); }, 'deleted a bookmark');
  }
  function moveBookmark(id, dir) {
    editBookmarks(list => {
      const w = (l) => {
        const i = l.findIndex(x => x.id === id);
        if (i >= 0) {
          const j = i + dir;
          if (j < 0 || j >= l.length) return l;
          const copy = [...l]; const [x] = copy.splice(i, 1); copy.splice(j, 0, x); return copy;
        }
        return l.map(x => ({ ...x, children: w(x.children || []) }));
      };
      return w(list);
    }, null);
  }
  // Nesting is a move between two levels of one tree, so it is done as remove
  // then insert rather than as two separate edits that can leave the item in
  // both places if the second one throws.
  // Nesting moves a node between two levels of one tree. It is done as
  // remove-then-insert against a snapshot of the ORIGINAL tree, because the
  // answer to "which sibling is above it" stops existing the moment it is
  // removed.
  function nestBookmark(id, dir) {
    editBookmarks(list => {
      let taken = null;
      const remove = (l) => l.filter(x => { if (x.id === id) { taken = x; return false; } return true; })
        .map(x => ({ ...x, children: remove(x.children || []) }));
      const stripped = remove(list);
      if (!taken) return list;

      if (dir === 'in') {
        let prevId = null;
        const findPrev = (l) => {
          for (let k = 0; k < l.length; k++) {
            if (l[k].id === id) { prevId = k > 0 ? l[k - 1].id : null; return true; }
            if (findPrev(l[k].children || [])) return true;
          }
          return false;
        };
        findPrev(list);
        if (!prevId) return list;      // nothing above it at this level
        const put = (l) => l.map(x => x.id === prevId
          ? { ...x, children: [...(x.children || []), taken] }
          : { ...x, children: put(x.children || []) });
        return put(stripped);
      }

      let parentId = null;
      const findParent = (l, pid) => {
        for (let k = 0; k < l.length; k++) {
          if (l[k].id === id) { parentId = pid; return true; }
          if (findParent(l[k].children || [], l[k].id)) return true;
        }
        return false;
      };
      findParent(list, null);
      if (!parentId) return list;      // already at the top level
      const put = (l) => {
        const out = [];
        l.forEach(x => {
          out.push({ ...x, children: put(x.children || []) });
          if (x.id === parentId) out.push(taken);
        });
        return out;
      };
      return put(stripped);
    }, null);
  }
  // The PDF's own outline. Destinations are references into the file, so each
  // one is resolved to a real page index through pdf.js rather than guessed.
  function importOutline() {
    const assetId = plan.length ? plan[0].assetId : body.originalAssetId;
    if (!assetId) return;
    setBusy('Reading the PDF outline…');
    officePdfDocument(assetId).then(pdf => pdf.getOutline().then(outline => {
      if (!outline || !outline.length) { setBusy('This PDF has no outline of its own.'); return null; }
      const pageOf = (dest) => {
        if (!dest) return Promise.resolve(0);
        const resolve = typeof dest === 'string' ? pdf.getDestination(dest) : Promise.resolve(dest);
        return resolve.then(d => (d && d[0] ? pdf.getPageIndex(d[0]) : 0)).catch(() => 0);
      };
      const convert = (items) => Promise.all(items.map(it =>
        pageOf(it.dest).then(pi => convert(it.items || []).then(kids =>
          makePdfBookmark({ title: it.title || 'Bookmark', page: pi, children: kids })))));
      return convert(outline).then(list => {
        setBusy(null);
        commit(b => { b.bookmarks = [...(b.bookmarks || []), ...list]; },
          `imported ${list.length} bookmark${list.length === 1 ? '' : 's'} from the PDF's own outline`);
      });
    })).catch(err => setBusy('The outline could not be read: ' + (err && err.message)));
  }

  // ── forms ───────────────────────────────────────────────────────────────
  function setField(id, patch) {
    if (!canEdit) return;
    if (patch === null) {
      commit(b => { b.fields = (b.fields || []).filter(f => f.id !== id); }, 'deleted a form field');
      setSelectedId(null);
      return;
    }
    commit(b => { b.fields = (b.fields || []).map(f => f.id === id ? { ...f, ...patch } : f); }, null);
  }
  function fillField(id, value) {
    if (!canEdit) return;
    commit(b => {
      b.fields = (b.fields || []).map(f => f.id === id ? { ...f, value } : f);
      const filled = (b.fields || []).filter(f => f.value !== '' && f.value != null && f.value !== false).length;
      b.formStatus = filled ? 'In Progress' : 'Fields Placed';
    }, null);
  }
  function addAcroFields(list) {
    commit(b => {
      const existing = new Set((b.fields || []).map(f => f.name));
      const add = list.filter(f => !existing.has(f.name)).map(f => makePdfField(f));
      b.fields = [...(b.fields || []), ...add];
      if (add.length) b.formStatus = 'Fields Placed';
    }, `read ${list.length} field${list.length === 1 ? '' : 's'} out of the PDF's own form`);
  }
  // §51. The value is read NOW and stored, so the filled form still says what it
  // said on the day it was filled even after the project record moves on.
  function fillFromLeon() {
    commit(b => {
      b.fields = (b.fields || []).map(f => f.leonSource
        ? { ...f, value: officePdfViewResolveLeon(ctx, doc, f.leonSource), filledFrom: f.leonSource, filledDate: todayISO() }
        : f);
    }, 'filled the linked form fields from LEON data');
  }
  // §52. The other direction is a REVIEW, never a write. The reviewed result is
  // filed against the document and written to the project's change log; the
  // change to the actual record is still made by a person, in the module that
  // owns it.
  function confirmReviewToLeon(target, note) {
    const fields = {};
    (body.fields || []).forEach(f => { if (f.name) fields[f.name] = f.value; });
    commit(b => {
      b.extractions = [...(b.extractions || []),
        makePdfExtraction({ kind: 'Form', page: 0, fields, reviewed: true, targetModule: target,
                            appliedDate: null, appliedBy: null, note: note || '' })];
      b.formStatus = 'Submitted';
    }, `reviewed this form as ${target || 'LEON'} data — ${Object.keys(fields).length} field${Object.keys(fields).length === 1 ? '' : 's'}. Nothing was written to a record automatically.`);
    setReviewModal(null);
  }

  // ── signatures ──────────────────────────────────────────────────────────
  function sign({ kind, fieldId, role, typedName, imageRef }) {
    if (!canEdit) return;
    if (kind === 'LEON Approval' && ctx.isViewingAs) return;
    const field = fieldId ? (body.fields || []).find(f => f.id === fieldId) : null;
    const page = field ? field.page : current;
    const rect = field ? field.rect : { x: 0.08, y: 0.82, w: 0.28, h: 0.06 };
    commit(b => {
      b.signatures = [...(b.signatures || []),
        makePdfSignature({ kind, fieldId: fieldId || null, page, pageId: (b.pages[page] || {}).id || null, rect,
                           signerId: ctx.currentUserId, signerName: ctx.currentUserName,
                           signerTitle: (team.find(p => p.id === ctx.currentUserId) || {}).title || '',
                           role: role || (field && field.signerRole) || '',
                           imageRef: imageRef || null, typedName: typedName || '',
                           signedDate: todayISO(), signedTime: officePdfViewNowTime(),
                           revision: doc.revision || 0 }, ctx.currentUserName)];
      const sigFields = (b.fields || []).filter(f => f.type === 'Signature' || f.type === 'Initial');
      const signedAll = sigFields.length && sigFields.every(f => b.signatures.some(s => s.fieldId === f.id));
      b.signatureStatus = signedAll ? 'Signed' : b.signatures.length ? 'Partially Signed' : 'Unsigned';
    }, `${kind === 'LEON Approval' ? 'approved' : 'signed'} this document as ${role || 'signatory'} (${kind})`);
  }
  function removeSignature(id) {
    commit(b => {
      b.signatures = (b.signatures || []).filter(s => s.id !== id);
      b.signatureStatus = b.signatures.length ? 'Partially Signed' : 'Unsigned';
    }, 'removed a signature');
  }
  function requestSignature({ signerIds, sequence, message, dueDate }) {
    commit(b => {
      b.signatureRequests = [...(b.signatureRequests || []),
        makePdfSignatureRequest({ signerIds, sequence, message, dueDate }, ctx.currentUserName)];
      b.signatureStatus = 'Awaiting';
    }, `asked ${signerIds.length} ${signerIds.length === 1 ? 'person' : 'people'} to sign`);
    notifyPeople('approval.pending', signerIds, `Signature requested — ${doc.name}`,
      `${ctx.currentUserName} asked you to sign "${doc.name}"${dueDate ? `, due ${dueDate}` : ''}.${message ? ' — ' + message : ''}`);
  }
  // §47. Once signing is finished the revision is CLOSED. Nothing is edited
  // under a signature; changing anything forks a new revision, and the signed
  // one stays exactly as it was signed.
  function lockRevision() {
    commit(b => { b.locked = true; b.signatureStatus = 'Locked'; },
      `locked revision ${doc.revision || 0} — signing complete. Further edits fork a new revision.`,
      { status: 'Approved' });
  }
  function createRevision({ number, reason, notes }) {
    const n = Number(number) || (doc.revision || 0) + 1;
    const snapshot = { n, date: todayISO(), by: ctx.currentUserName, formal: true,
                       note: `${reason || 'Revision'}${notes ? ' — ' + notes : ''}`, body: cloneDeep(body) };
    const versions = officePdfViewTrimVersions([...(doc.versions || []), snapshot]);
    const next = cloneDeep(body);
    next.locked = false;
    next.signatureStatus = (next.signatures || []).length ? 'Partially Signed' : 'Unsigned';
    undoRef.current = [...undoRef.current.slice(-(PDFVIEW_UNDO_LIMIT - 1)), cloneDeep(body)];
    setUndoDepth(undoRef.current.length);
    onChange({ ...doc, revision: n, status: 'Draft', versions, body: next,
               modifiedBy: ctx.currentUserName, modifiedDate: todayISO() });
    logToProject(`created revision ${n}${reason ? ` — ${reason}` : ''}${notes ? `. ${notes}` : ''}`);
    setRevModal(null);
  }
  function restoreVersion(v) {
    if (!v || !v.body) return;
    undoRef.current = [...undoRef.current.slice(-(PDFVIEW_UNDO_LIMIT - 1)), cloneDeep(body)];
    setUndoDepth(undoRef.current.length);
    onChange({ ...doc, body: cloneDeep(v.body), modifiedBy: ctx.currentUserName, modifiedDate: todayISO() });
    logToProject(`restored the markup and page order from revision ${v.n}`);
  }

  // ── derived views the surface needs ─────────────────────────────────────
  const annotationsByPage = useMemo(() => {
    const m = {};
    (body.annotations || []).forEach(a => {
      const merged = live && live.id === a.id ? { ...a, ...live.patch } : a;
      (m[merged.page] = m[merged.page] || []).push(merged);
    });
    return m;
  }, [body.annotations, live]);
  const fieldsByPage = useMemo(() => {
    const m = {};
    (body.fields || []).forEach(f => {
      const merged = live && live.id === f.id ? { ...f, ...live.patch } : f;
      (m[merged.page] = m[merged.page] || []).push(merged);
    });
    return m;
  }, [body.fields, live]);
  const signaturesByPage = useMemo(() => {
    const m = {};
    (body.signatures || []).forEach(s => { (m[s.page] = m[s.page] || []).push(s); });
    return m;
  }, [body.signatures]);
  const redactionsByPage = useMemo(() => {
    const m = {};
    (body.redactions || []).forEach(r => { (m[r.page] = m[r.page] || []).push(r); });
    return m;
  }, [body.redactions]);
  const measurements = useMemo(() => (body.annotations || []).filter(a => a.type === 'Measurement'), [body.annotations]);
  const orphans = useMemo(() => (body.annotations || []).filter(a => a.page < 0), [body.annotations]);

  function jump(planIndex) { setJumpTo({ planIndex, nonce: Date.now() }); }

  // Delete removes whatever is selected — a markup or a field, whichever it is.
  useEffect(() => {
    function onKey(e) {
      if (!canEdit || !selectedId) return;
      const t = e.target;
      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
      if (e.key === 'Delete' || e.key === 'Backspace') {
        e.preventDefault();
        if ((body.annotations || []).some(a => a.id === selectedId)) deleteAnnotation(selectedId);
        else if ((body.fields || []).some(f => f.id === selectedId)) setField(selectedId, null);
      }
      if (e.key === 'Escape') { setTool('select'); setSelectedId(null); setCropDraft(null); }
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); undo(); }
    }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [selectedId, canEdit, body.annotations, body.fields]);

  // ── the contextual strip under the header ───────────────────────────────
  function ToolStrip() {
    if (group === 'comment' || group === 'edit') {
      const groups = [...new Set(PDFVIEW_MARKUP.map(m => m.group))];
      return (
        <>
        {group === 'edit' && (
          // Say it here rather than let someone hunt for a text cursor that does
          // not exist. Editing the words already inside a PDF means rewriting
          // its content streams and re-embedding its fonts; pdf-lib does not do
          // that, and faking it by covering text with a white box is how a
          // "corrected" contract still carries the old figure underneath.
          <div className="px-3 py-2 border-b border-[var(--leon-line)] bg-[#fbf6e9] text-[11px] text-[#6b5726] leading-snug">
            <b>The text already inside a PDF cannot be retyped here.</b> What you can do instead: add a
            <b> Text Box</b> or <b>Callout</b> over it, <b>mark it for redaction</b> so the content is
            genuinely removed on export, or <b>replace the whole page</b> from the Pages panel.
          </div>
        )}
        <div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b border-[var(--leon-line)] bg-[var(--leon-cream)]">
          {groups.map(g => (
            <span key={g} className="flex items-center gap-0.5">
              <span className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/35 mr-1">{g}</span>
              {PDFVIEW_MARKUP.filter(m => m.group === g).map(m => (
                <button key={m.type} title={m.type} disabled={!canEdit}
                  onClick={() => { setTool(tool === m.type ? 'select' : m.type);
                                   setToolOpts(o => ({ ...o, color: m.color, opacity: m.opacity })); }}
                  className={`w-8 h-8 rounded-md border text-sm ${tool === m.type
                    ? 'border-[var(--leon-brown)] bg-white ring-2 ring-[var(--leon-brown)]'
                    : 'border-[var(--leon-line)] bg-white hover:border-[var(--leon-brown-light,#b08968)]'} disabled:opacity-35`}>
                  {m.icon}
                </button>
              ))}
            </span>
          ))}
          <span className="w-px h-6 bg-[var(--leon-line)]" />
          {PDFVIEW_COLORS.map(c => (
            <button key={c} title={c} onClick={() => setToolOpts(o => ({ ...o, color: c }))}
              className={`w-5 h-5 rounded-full border-2 ${toolOpts.color === c ? 'border-[var(--leon-black)]' : 'border-white'}`}
              style={{ background: c }} />
          ))}
          <Select className="!w-auto !py-1 !text-xs" value={toolOpts.weight}
            onChange={e => setToolOpts(o => ({ ...o, weight: Number(e.target.value) }))}>
            {PDFVIEW_WEIGHTS.map(w => <option key={w} value={w}>{w} px</option>)}
          </Select>
          <label className="text-[11px] flex items-center gap-1">
            Opacity
            <input type="range" min="0.1" max="1" step="0.05" value={toolOpts.opacity}
              onChange={e => setToolOpts(o => ({ ...o, opacity: Number(e.target.value) }))} />
          </label>
          {tool === 'Stamp' && <span className="text-[11px] text-[var(--leon-brown)] font-semibold">Stamp: {stampText}</span>}
          <button className="text-[11px] underline text-[var(--leon-brown)]" onClick={() => setRightTab('stamps')}>Stamps…</button>
        </div>
        </>
      );
    }
    if (group === 'organize') {
      return (
        <div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b border-[var(--leon-line)] bg-[var(--leon-cream)]">
          <span className="text-[11px] text-[var(--leon-black)]/55">
            Page work happens in the <b>Pages</b> panel on the left — select pages there, then rotate,
            reorder, delete, extract or insert. Nothing here rewrites the file.
          </span>
          <Button size="sm" variant="outline" onClick={() => { setLeftOpen(true); setLeftTab('pages'); }}>Open Pages</Button>
          <Button size="sm" variant={tool === 'redact' ? 'primary' : 'outline'} disabled={!canEdit}
            onClick={() => setTool(tool === 'redact' ? 'select' : 'redact')}>Mark for redaction</Button>
          {/* One button labelled "Combine / split / package" opened the COMBINE
              tool and nothing else, so `OfficePdfToolSplit` — a fully built
              "Split & extract pages" panel — was unreachable, and the button's
              own label promised something it did not do. Three buttons, three
              tools. Same class of bug as the four panels that had no entry
              point here before; worth re-running the reachability check on this
              file after any change to PDFVIEW_TOOLBAR or the Tool map. */}
          {['organize', 'combine', 'package'].map(k => (
            <Button key={k} size="sm" variant="outline"
              onClick={() => { setToolsTool(k); setRightTab('tools'); setRightOpen(true); }}>
              {PDFTOOL_TOOLS[k].icon} {PDFTOOL_TOOLS[k].label}
            </Button>
          ))}
        </div>
      );
    }
    if (group === 'fillsign') {
      return (
        <div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b border-[var(--leon-line)] bg-[var(--leon-cream)]">
          <Button size="sm" variant={formMode === 'fill' ? 'primary' : 'outline'} onClick={() => { setFormMode('fill'); setTool('select'); setRightTab('forms'); }}>Fill</Button>
          <Button size="sm" variant={formMode === 'design' ? 'primary' : 'outline'} disabled={!canEdit} onClick={() => { setFormMode('design'); setRightTab('forms'); }}>Design fields</Button>
          <Button size="sm" variant="outline" onClick={() => setRightTab('signatures')}>Signatures</Button>
          <span className="text-[11px] text-[var(--leon-black)]/50">
            {(body.fields || []).length} field{(body.fields || []).length === 1 ? '' : 's'} ·
            {' '}{(body.signatures || []).length} signature{(body.signatures || []).length === 1 ? '' : 's'}
          </span>
        </div>
      );
    }
    if (group === 'measure') {
      return (
        <div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b border-[var(--leon-line)] bg-[var(--leon-cream)]">
          {PDFVIEW_MEASURE.map(m => (
            <Button key={m.key} size="sm" variant={tool === 'measure:' + m.key ? 'primary' : 'outline'} disabled={!canEdit}
              onClick={() => setTool(tool === 'measure:' + m.key ? 'select' : 'measure:' + m.key)}>{m.icon} {m.label}</Button>
          ))}
          <Button size="sm" variant="outline" onClick={() => setRightTab('measure')}>Calibration & list</Button>
          <span className="text-[11px] text-[var(--leon-black)]/55">
            {(body.metadata || {}).calibration
              ? `Calibrated: ${(body.metadata.calibration.perFraction).toFixed(2)} ${body.metadata.calibration.unit} per page width`
              : 'Not calibrated — results are page fractions until you set a known length.'}
          </span>
        </div>
      );
    }
    return null;
  }

  const toolsPanelFor = (key) => (
    typeof OfficePdfToolsPanel === 'function'
      ? <OfficePdfToolsPanel ctx={ctx} doc={doc} onChange={onChange} tool={key} />
      : (
        <div className="p-4 text-sm text-[var(--leon-black)]/55">
          <div className="font-semibold mb-1">The PDF tools panel is not loaded.</div>
          Combine, split, packages, watermarks, Bates numbering, applying redactions, compare, extraction,
          optimize and export live in <code>softwares/office-pdf-tools.jsx</code>, which is missing from
          this build or is not listed in index.html.
        </div>
      )
  );

  const leftTabs = [
    { key: 'pages', label: 'Pages', icon: '🗂️' },
    { key: 'bookmarks', label: 'Bookmarks', icon: '🔖' },
    { key: 'search', label: 'Search', icon: '🔍' },
    { key: 'package', label: 'Package', icon: '📚' },
  ];
  const rightTabs = [
    { key: 'comments', label: 'Comments', icon: '💬' },
    { key: 'properties', label: 'Properties', icon: 'ⓘ' },
    { key: 'forms', label: 'Forms', icon: '🧾' },
    { key: 'signatures', label: 'Signatures', icon: '✍️' },
    { key: 'data', label: 'Document data', icon: '🔗' },
    { key: 'stamps', label: 'Stamps', icon: '🔖' },
    { key: 'measure', label: 'Measure', icon: '📏' },
    { key: 'ocr', label: 'OCR', icon: '👁' },
    ...(mode === 'ai' ? [{ key: 'ai', label: 'AI', icon: '✨' }] : []),
    ...(toolsTool ? [{ key: 'tools', label: 'Tools', icon: '🛠' }] : []),
  ];

  const shell = fullScreen
    ? 'fixed inset-0 z-50 bg-white flex flex-col'
    : 'flex flex-col border border-[var(--leon-line)] rounded-xl overflow-hidden bg-white';

  return (
    <div className={shell} style={fullScreen ? {} : { height: '78vh', minHeight: 560 }}>
      {/* ── header ─────────────────────────────────────────────────────── */}
      <div className="border-b border-[var(--leon-line)]">
        <div className="flex items-center gap-2 px-3 py-2 flex-wrap">
          <span className="font-bold text-sm truncate max-w-[22ch]" title={doc.name}>📕 {doc.name}</span>
          <Badge tone={doc.status === 'Approved' ? 'green' : doc.status === 'Superseded' ? 'red' : 'neutral'}>{doc.status}</Badge>
          <span className="text-[11px] text-[var(--leon-black)]/45">rev {doc.revision || 0} · {plan.length} pages</span>
          {body.locked && <Badge tone="red">🔒 Locked</Badge>}
          {editable === false && <Badge tone="neutral">Read only</Badge>}

          <span className="w-px h-5 bg-[var(--leon-line)] mx-1" />
          {PDFVIEW_TOOLBAR.filter(t => t.modes.includes(mode) || mode === 'ai').map(t => (
            <button key={t.key} title={t.label}
              onClick={() => { if (t.tools) { setToolsTool(t.key); setRightTab('tools'); setRightOpen(true); } else { setGroup(t.key); if (t.key === 'select') setTool('select'); } }}
              className={`px-2 py-1 rounded-md text-xs font-semibold border ${group === t.key && !t.tools
                ? 'border-[var(--leon-brown)] text-[var(--leon-brown)] bg-[var(--leon-cream)]'
                : 'border-transparent text-[var(--leon-black)]/60 hover:bg-[var(--leon-cream)]'}`}>
              <span aria-hidden="true" className="mr-1">{t.icon}</span>{t.label}
            </button>
          ))}

          <span className="ml-auto flex items-center gap-1">
            <Select className="!w-auto !py-1 !text-xs" value={mode} onChange={e => setMode(e.target.value)}
              title={(PDFVIEW_MODES.find(m => m.key === mode) || {}).hint}>
              {PDFVIEW_MODES.map(m => <option key={m.key} value={m.key}>{m.label}</option>)}
            </Select>
            <IconAction icon="↶" title={`Undo (${undoDepth} step${undoDepth === 1 ? '' : 's'} in this session)`}
              disabled={!undoDepth || !canEdit} onClick={undo} />
            <IconAction icon={fullScreen ? '⤡' : '⤢'} title={fullScreen ? 'Leave full screen' : 'Full screen'}
              onClick={() => setFullScreen(f => !f)} />
          </span>
        </div>

        <div className="flex items-center gap-1.5 px-3 py-1.5 border-t border-[var(--leon-line)] flex-wrap">
          <IconAction icon="◧" title="Show or hide the left panel" onClick={() => setLeftOpen(o => !o)} />
          <span className="w-px h-5 bg-[var(--leon-line)]" />
          {PDFVIEW_LAYOUTS.map(l => (
            <button key={l.key} title={l.label} onClick={() => setLayout(l.key)}
              className={`px-2 h-7 rounded-md border text-xs ${layout === l.key
                ? 'border-[var(--leon-brown)] text-[var(--leon-brown)]' : 'border-[var(--leon-line)] text-[var(--leon-black)]/55'}`}>
              {l.icon}
            </button>
          ))}
          <span className="w-px h-5 bg-[var(--leon-line)]" />
          <IconAction icon="−" title="Zoom out" onClick={() => { setZoomMode('fixed'); setZoomValue(v => officePdfViewClamp(v / 1.25, 0.05, 8)); }} />
          <Select className="!w-auto !py-1 !text-xs"
            value={zoomMode === 'fixed' ? String(Math.round(zoomValue * 100)) : zoomMode}
            onChange={e => {
              const v = e.target.value;
              if (v === 'fitWidth' || v === 'fitPage') { setZoomMode(v); return; }
              setZoomMode('fixed'); setZoomValue(Number(v) / 100);
            }}>
            <option value="fitWidth">Fit width</option>
            <option value="fitPage">Fit page</option>
            {PDFVIEW_ZOOM_STEPS.map(z => <option key={z} value={String(Math.round(z * 100))}>{Math.round(z * 100)}%</option>)}
            {zoomMode === 'fixed' && !PDFVIEW_ZOOM_STEPS.some(z => Math.round(z * 100) === Math.round(zoomValue * 100)) && (
              <option value={String(Math.round(zoomValue * 100))}>{Math.round(zoomValue * 100)}%</option>
            )}
          </Select>
          <IconAction icon="+" title="Zoom in" onClick={() => { setZoomMode('fixed'); setZoomValue(v => officePdfViewClamp(v * 1.25, 0.05, 8)); }} />
          <IconAction icon="1:1" title="Actual size" onClick={() => { setZoomMode('fixed'); setZoomValue(1); }} className="!w-auto px-1.5" />
          <span className="w-px h-5 bg-[var(--leon-line)]" />
          {/* Rotating the VIEW turns the pages on screen. It is the same field
              the Pages panel edits, because "rotate what I am looking at" and
              "rotate this page" are the same intent, and two separate rotations
              would disagree the moment one was exported. */}
          <IconAction icon="↺" title="Rotate the current page left" disabled={!canEdit} onClick={() => rotatePages([current], -90)} />
          <IconAction icon="↻" title="Rotate the current page right" disabled={!canEdit} onClick={() => rotatePages([current], 90)} />
          <span className="w-px h-5 bg-[var(--leon-line)]" />
          <span className="text-xs text-[var(--leon-black)]/55">Page</span>
          <input value={current + 1} onChange={e => { const n = Number(e.target.value) - 1; if (n >= 0 && n < plan.length) { setCurrent(n); jump(n); } }}
            className="w-12 text-xs text-center border border-[var(--leon-line)] rounded px-1 py-1" />
          <span className="text-xs text-[var(--leon-black)]/45">/ {plan.length}</span>
          <span className="ml-auto flex items-center gap-1">
            <span className="text-[11px] text-[var(--leon-black)]/40">{tool === 'select' ? 'Select' : tool}</span>
            <IconAction icon="◨" title="Show or hide the right panel" onClick={() => setRightOpen(o => !o)} />
          </span>
        </div>

        <ToolStrip />
        {cropDraft && (
          <div className="flex items-center gap-2 px-3 py-2 bg-[var(--leon-cream)] border-t border-[var(--leon-line)]">
            <span className="text-xs">Crop page {cropDraft.planIndex + 1} to the box you drew?</span>
            <Button size="sm" onClick={applyCrop}>Apply</Button>
            <Button size="sm" variant="ghost" onClick={() => { setCropDraft(null); setTool('select'); }}>Cancel</Button>
            <span className="text-[11px] text-[var(--leon-black)]/50">The crop is recorded on the page plan — the file is untouched until export.</span>
          </div>
        )}
        {busy && (
          <div className="px-3 py-1.5 text-xs bg-[var(--leon-cream)] border-t border-[var(--leon-line)] flex items-center gap-2">
            <span>{busy}</span>
            <button className="underline" onClick={() => setBusy(null)}>Dismiss</button>
          </div>
        )}
      </div>

      <div className="px-3 pt-2"><OfficePdfViewSupersededBanner ctx={ctx} doc={doc} body={body} /></div>
      {!!orphans.length && (
        <div className="mx-3 mt-2 rounded-md border border-[#e6dcc6] bg-[#fbf6e9] px-3 py-1.5 text-[11px] text-[#6b5726]">
          {orphans.length} markup{orphans.length === 1 ? '' : 's'} {orphans.length === 1 ? 'is' : 'are'} filed against a
          page that is no longer in this document. They are kept, not deleted — put the page back and they reappear.
        </div>
      )}

      {/* ── body ───────────────────────────────────────────────────────── */}
      <div className="flex-1 min-h-0 flex">
        {leftOpen && (
          <div className="w-64 shrink-0 border-r border-[var(--leon-line)] flex flex-col min-h-0">
            <div className="flex border-b border-[var(--leon-line)]">
              {leftTabs.map(t => (
                <button key={t.key} onClick={() => setLeftTab(t.key)} title={t.label}
                  className={`flex-1 py-2 text-xs font-semibold border-b-2 ${leftTab === t.key
                    ? 'border-[var(--leon-brown)] text-[var(--leon-brown)]' : 'border-transparent text-[var(--leon-black)]/45'}`}>
                  {t.icon}
                </button>
              ))}
            </div>
            <div className="flex-1 min-h-0">
              {leftTab === 'pages' && (
                <OfficePdfViewPagesPanel body={body} plan={plan} sizes={sizes} editable={canEdit}
                  current={current} selection={pageSel} setSelection={setPageSel}
                  onJump={jump} onRotate={rotatePages} onDelete={deletePages} onDuplicate={duplicatePages}
                  onExtract={extractPages} onInsert={insertPdf} onReplace={replacePage}
                  onCrop={(i) => { setTool('crop'); jump(i); }} onProps={(i) => setPageProps(i)}
                  onReorder={reorderPages} />
              )}
              {leftTab === 'bookmarks' && (
                <div className="flex flex-col h-full min-h-0">
                  <div className="flex items-center gap-1 p-2 border-b border-[var(--leon-line)]">
                    <Button size="sm" variant="outline" disabled={!canEdit} onClick={() => addBookmark(null)}>+ At page {current + 1}</Button>
                    <Button size="sm" variant="ghost" disabled={!canEdit} onClick={importOutline}>Import the PDF's outline</Button>
                  </div>
                  <div className="flex-1 min-h-0 overflow-auto p-1">
                    {!(body.bookmarks || []).length ? <EmptyState text="No bookmarks yet." /> : (body.bookmarks || []).map(bm => (
                      <OfficePdfViewBookmarkRow key={bm.id} bm={bm} depth={0} editable={canEdit}
                        onJump={jump} onRename={renameBookmark} onDelete={deleteBookmark}
                        onMove={moveBookmark} onNest={nestBookmark} onAdd={addBookmark} />
                    ))}
                  </div>
                </div>
              )}
              {leftTab === 'search' && (
                <OfficePdfViewSearchPanel index={docIndex} indexState={indexState} onBuild={buildIndex}
                  onJump={jump} flashHit={flashHit} setFlashHit={setFlashHit}
                  query={query} setQuery={setQuery} caseSensitive={caseSensitive} setCaseSensitive={setCaseSensitive}
                  wholeWord={wholeWord} setWholeWord={setWholeWord} hits={hits} />
              )}
              {leftTab === 'package' && (
                <OfficePdfViewPackagePanel body={body} plan={plan} onJump={jump}
                  onOpenTools={() => { setToolsTool('package'); setRightTab('tools'); setRightOpen(true); }} />
              )}
            </div>
          </div>
        )}

        <OfficePdfViewSurface
          plan={plan} sizes={sizes} zoomMode={zoomMode} zoomValue={zoomValue} layout={layout}
          onZoomValue={setZoomValue} onCurrentPage={setCurrent} jumpTo={jumpTo} body={body}
          annotationsFor={i => annotationsByPage[i] || null}
          fieldsFor={i => fieldsByPage[i] || null}
          signaturesFor={i => signaturesByPage[i] || null}
          redactionsFor={i => redactionsByPage[i] || null}
          hitsFor={i => hitsByPage[i] || null} flashHit={flashHit}
          tool={tool} toolOpts={toolOpts} selectedId={selectedId} editable={canEdit}
          textFor={i => textByPlan[i] || null} showText={tool === 'select' || (PDFVIEW_MARKUP_BY_TYPE[tool] || {}).shape === 'text'}
          formMode={formMode} cropDraft={cropDraft} onCropDraft={setCropDraft}
          onSelect={setSelectedId} onCreate={createOnPage} onUpdate={updateAnnotation}
          onOpenComment={a => { setSelectedId(a.id); setRightTab('comments'); setRightOpen(true); }}
          onFieldChange={fillField} fullScreen={fullScreen} />

        {rightOpen && (
          <div className="w-80 shrink-0 border-l border-[var(--leon-line)] flex flex-col min-h-0">
            <div className="flex flex-wrap border-b border-[var(--leon-line)]">
              {rightTabs.map(t => (
                <button key={t.key} onClick={() => setRightTab(t.key)} title={t.label}
                  className={`px-2 py-2 text-xs font-semibold border-b-2 ${rightTab === t.key
                    ? 'border-[var(--leon-brown)] text-[var(--leon-brown)]' : 'border-transparent text-[var(--leon-black)]/45'}`}>
                  {t.icon}
                </button>
              ))}
            </div>
            <div className="flex-1 min-h-0">
              {rightTab === 'comments' && (
                <OfficePdfViewCommentsPanel body={body} team={team} editable={canEdit} selectedId={selectedId}
                  onSelect={setSelectedId} onUpdate={updateAnnotation} onDelete={deleteAnnotation}
                  onReply={replyToAnnotation} onJump={jump} currentUserName={ctx.currentUserName} />
              )}
              {rightTab === 'properties' && (
                <OfficePdfViewPropertiesPanel ctx={ctx} doc={doc} body={body} plan={plan} editable={canEdit}
                  assetInfo={assetInfo} indexState={indexState}
                  onSet={patch => commit(b => Object.assign(b, patch), null)}
                  onSetDoc={patch => setDocFields(patch, null)} />
              )}
              {rightTab === 'forms' && (
                <OfficePdfViewFormsPanel ctx={ctx} doc={doc} body={body} plan={plan} editable={canEdit}
                  formMode={formMode} setFormMode={setFormMode} tool={tool} setTool={setTool}
                  selectedId={selectedId} onSelect={setSelectedId} onField={setField}
                  onAddFieldsFromAcroForm={addAcroFields} onFillFromLeon={fillFromLeon}
                  onReviewToLeon={() => setReviewModal({ target: '', note: '' })} onJump={jump} />
              )}
              {rightTab === 'signatures' && (
                <OfficePdfViewSignaturesPanel ctx={ctx} doc={doc} body={body} editable={editable !== false}
                  onSign={sign} onRemoveSignature={removeSignature} onRequest={requestSignature}
                  onPlaceField={() => { setRightTab('forms'); setFormMode('design'); setTool('field:Signature'); }}
                  onLock={lockRevision} onNewRevision={() => setRevModal({ number: (doc.revision || 0) + 1, reason: '', notes: '' })}
                  onJump={jump} />
              )}
              {rightTab === 'data' && (
                <OfficePdfViewDataPanel ctx={ctx} doc={doc} body={body} editable={editable !== false}
                  onSetDoc={patch => setDocFields(patch, null)}
                  onSet={patch => commit(b => Object.assign(b, patch), null)}
                  onNewRevision={() => setRevModal({ number: (doc.revision || 0) + 1, reason: '', notes: '' })}
                  onRestore={restoreVersion} />
              )}
              {rightTab === 'stamps' && (
                <div className="p-3 overflow-auto h-full">
                  <OfficePdfViewStampPanel ctx={ctx} doc={doc} editable={canEdit} tool={tool} setTool={setTool}
                    stampText={stampText} setStampText={setStampText} />
                </div>
              )}
              {rightTab === 'measure' && (
                <OfficePdfViewMeasurePanel ctx={ctx} doc={doc} body={body}
                  calibration={(body.metadata || {}).calibration || null}
                  setCalibration={c => commit(b => { b.metadata = { ...(b.metadata || {}), calibration: c }; }, c ? null : 'cleared the measurement calibration')}
                  tool={tool} setTool={setTool} editable={canEdit} measurements={measurements}
                  onDelete={deleteAnnotation} onJump={jump}
                  onBeginCalibrate={(known, unit) => { setPendingCalib({ known, unit }); setTool('measure:calibrate'); }} />
              )}
              {rightTab === 'ocr' && (
                <div className="p-3 overflow-auto h-full">
                  <OfficePdfViewOcrPanel body={body} indexState={indexState} onBuild={buildIndex} editable={canEdit}
                    onSetStatus={v => commit(b => { b.ocrStatus = v; }, `set the OCR status to ${v}`)} />
                </div>
              )}
              {rightTab === 'ai' && (
                <div className="p-3 overflow-auto h-full">
                  <OfficePdfViewAiPanel suggestion={suggestion} category={body.category} editable={canEdit}
                    onAcceptSuggestion={() => commit(b => { b.category = suggestion.category; },
                      `filed as ${suggestion.category} from the keyword suggestion`)} />
                </div>
              )}
              {rightTab === 'tools' && <div className="overflow-auto h-full">{toolsPanelFor(toolsTool || 'export')}</div>}
            </div>
          </div>
        )}
      </div>

      {/* ── modals ─────────────────────────────────────────────────────── */}
      <Modal open={pageProps != null} onClose={() => setPageProps(null)} title="Page properties">
        {pageProps != null && (() => {
          const p = body.pages[pageProps] || {};
          const nat = sizes[p.assetId + ':' + p.index] || PDFVIEW_DEFAULT_PAGE;
          return (
            <div className="space-y-3 text-sm">
              <div className="grid grid-cols-2 gap-2">
                <div><div className="text-[11px] text-[var(--leon-black)]/45">Position in this document</div><b>{pageProps + 1} of {plan.length}</b></div>
                <div><div className="text-[11px] text-[var(--leon-black)]/45">Page in its source file</div><b>{p.index + 1}</b></div>
                <div><div className="text-[11px] text-[var(--leon-black)]/45">Size</div><b>{Math.round(nat.width)} × {Math.round(nat.height)} pt</b></div>
                <div><div className="text-[11px] text-[var(--leon-black)]/45">≈</div><b>{(nat.width / 72).toFixed(2)} × {(nat.height / 72).toFixed(2)} in</b></div>
                <div><div className="text-[11px] text-[var(--leon-black)]/45">LEON rotation</div><b>{p.rotate || 0}°</b></div>
                <div><div className="text-[11px] text-[var(--leon-black)]/45">Crop</div><b>{p.crop ? 'set' : 'none'}</b></div>
              </div>
              <Field label="Page label" hint="What this page is called in the thumbnails and the printed index.">
                <TextInput value={(body.pageLabels || {})[p.id] || ''} disabled={!canEdit}
                  onChange={e => commit(b => { b.pageLabels = { ...(b.pageLabels || {}), [p.id]: e.target.value }; }, null)} />
              </Field>
              {p.crop && canEdit && <Button size="sm" variant="ghost" onClick={() => commit(b => { b.pages = b.pages.map((x, i) => i === pageProps ? { ...x, crop: null } : x); }, `removed the crop on page ${pageProps + 1}`)}>Remove the crop</Button>}
              <p className="text-[11px] text-[var(--leon-black)]/50">
                This page is a pointer at page {p.index + 1} of a stored file. Rotating or cropping it changes
                the pointer, never the file.
              </p>
            </div>
          );
        })()}
      </Modal>

      <Modal open={!!revModal} onClose={() => setRevModal(null)} title="Create a new revision"
        footer={<>
          <Button variant="ghost" onClick={() => setRevModal(null)}>Cancel</Button>
          <Button disabled={!revModal || !revModal.reason.trim()} onClick={() => createRevision(revModal)}>Create revision</Button>
        </>}>
        {revModal && (
          <div className="space-y-3">
            <p className="text-[12px] text-[var(--leon-black)]/60 leading-snug">
              A revision is a decision other people will cite, not an autosave. It takes a number, a reason
              and notes — and it unlocks the document so work can continue, while the signed revision stays
              exactly as it was signed.
            </p>
            <Field label="Revision number">
              <TextInput type="number" value={revModal.number} onChange={e => setRevModal(r => ({ ...r, number: e.target.value }))} />
            </Field>
            <Field label="Reason" hint="Required — 'why' is the part anyone reads later.">
              <TextInput value={revModal.reason} onChange={e => setRevModal(r => ({ ...r, reason: e.target.value }))}
                placeholder="Client comments incorporated" />
            </Field>
            <Field label="Notes">
              <TextArea rows={3} value={revModal.notes} onChange={e => setRevModal(r => ({ ...r, notes: e.target.value }))} />
            </Field>
          </div>
        )}
      </Modal>

      <Modal open={!!reviewModal} onClose={() => setReviewModal(null)} wide title="Review this form as LEON data"
        footer={<>
          <Button variant="ghost" onClick={() => setReviewModal(null)}>Cancel</Button>
          <Button onClick={() => confirmReviewToLeon(reviewModal.target, reviewModal.note)}>File the reviewed result</Button>
        </>}>
        {reviewModal && (
          <div className="space-y-3">
            <div className="rounded-md border border-[#e6dcc6] bg-[#fbf6e9] px-3 py-2 text-[12px] text-[#6b5726]">
              <b>Nothing is written to a project record here.</b> Filing this records what the form says, who
              reviewed it and when, and writes a line to the project's change log. Changing the actual record
              is still done by a person in the module that owns it.
            </div>
            <div className="max-h-64 overflow-auto border border-[var(--leon-line)] rounded">
              <table className="w-full text-[12px]">
                <thead className="bg-[var(--leon-cream)]"><tr><th className="text-left px-2 py-1">Field</th><th className="text-left px-2 py-1">Value</th></tr></thead>
                <tbody>
                  {(body.fields || []).map(f => (
                    <tr key={f.id} className="border-t border-[var(--leon-line)]">
                      <td className="px-2 py-1">{f.name || '(unnamed)'}</td>
                      <td className="px-2 py-1 font-semibold">{f.type === 'Checkbox' ? (f.value ? 'yes' : 'no') : (f.value || '—')}</td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
            <Field label="Which module is this for?">
              <TextInput value={reviewModal.target} onChange={e => setReviewModal(r => ({ ...r, target: e.target.value }))}
                placeholder="Procurement, Financial Hub, Installation…" />
            </Field>
            <Field label="Note">
              <TextArea rows={2} value={reviewModal.note} onChange={e => setReviewModal(r => ({ ...r, note: e.target.value }))} />
            </Field>
          </div>
        )}
      </Modal>

      <Modal open={!!commentModal} onClose={() => setCommentModal(null)} title="Add the note">
        <OfficePdfViewNoteEditor
          annotation={(body.annotations || [])[(body.annotations || []).length - 1]}
          onSave={text => { const last = (body.annotations || [])[(body.annotations || []).length - 1];
                            if (last) updateAnnotation(last.id, { text }); setCommentModal(null); }}
          onCancel={() => setCommentModal(null)} />
      </Modal>
    </div>
  );
}

// The little editor that opens the moment a note is placed. It is a separate
// component so its draft text is local — typing into it must not re-render the
// whole viewer on every keystroke.
function OfficePdfViewNoteEditor({ annotation, onSave, onCancel }) {
  const [text, setText] = useState((annotation && annotation.text) || '');
  return (
    <div className="space-y-3">
      <Field label="Note" hint="Type @ and a colleague's name to notify them.">
        <TextArea rows={4} autoFocus value={text} onChange={e => setText(e.target.value)} />
      </Field>
      <div className="flex justify-end gap-2">
        <Button variant="ghost" onClick={onCancel}>Skip</Button>
        <Button onClick={() => onSave(text)}>Save the note</Button>
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════════════ record housekeeping

// Markup is anchored to a page ID as well as a page number. The number is what
// the renderer uses and it is derived, so after any change to the ORDER PLAN it
// is recomputed here from the id. Anything whose page has left the document
// gets page -1 rather than being deleted: it stops rendering, it is reported,
// and putting the page back brings it straight back. Deleting someone's markup
// because a page was reordered would be indefensible.
function officePdfViewBackfillAnchors(b) {
  const at = (i) => (b.pages || [])[i];
  const fix = (list) => (list || []).map(x => {
    if (x.pageId || !(x.page >= 0)) return x;
    const pg = at(x.page);
    return pg ? { ...x, pageId: pg.id } : x;
  });
  b.annotations = fix(b.annotations);
  b.fields = fix(b.fields);
  b.signatures = fix(b.signatures);
  b.redactions = fix(b.redactions);
}

function officePdfViewReanchor(b) {
  const posOf = {};
  (b.pages || []).forEach((p, i) => { posOf[p.id] = i; });
  const fix = (list) => (list || []).map(x => {
    if (!x.pageId) return x;                    // older records have no anchor; leave them alone
    const at = posOf[x.pageId];
    return { ...x, page: at === undefined ? -1 : at };
  });
  b.annotations = fix(b.annotations);
  b.fields = fix(b.fields);
  b.signatures = fix(b.signatures);
  b.redactions = fix(b.redactions);
}

// §111. An autosave entry says the document was worked on; it carries no body,
// because a body snapshot per keystroke would fill the Hub's whole localStorage
// budget on one large document. One entry per person per day is enough to
// answer "who was in this and when" — which is the only question it exists for.
function officePdfViewAutosave(doc, by) {
  const list = [...(doc.versions || [])];
  const last = list[list.length - 1];
  const today = todayISO();
  if (last && !last.formal && last.by === by && last.date === today) return list;
  list.push({ n: (doc.revision || 0), date: today, by: by || '', formal: false, note: 'Autosaved' });
  return officePdfViewTrimVersions(list);
}
// Formal revisions keep a body snapshot; only the most recent few do, because a
// snapshot of a three-hundred-page plan is not free. The older entries keep
// their number, date, reason and author — the history stays complete, only the
// ability to roll back to the oldest ones goes.
function officePdfViewTrimVersions(list) {
  let kept = 0;
  const out = [...list].reverse().map(v => {
    if (!v.body) return v;
    kept++;
    return kept <= PDFVIEW_BODY_SNAPSHOTS ? v : { ...v, body: null, bodyDropped: true };
  }).reverse();
  const formal = out.filter(v => v.formal);
  const auto = out.filter(v => !v.formal).slice(-Math.max(0, PDFVIEW_VERSION_LIMIT - formal.length));
  return out.filter(v => v.formal || auto.includes(v));
}

// What a marked redaction actually covers, read off the text layer. It is
// recorded so the review step before applying can show WHAT is about to be
// removed rather than just where.
function officePdfViewQuoteIn(pageText, rect, planPage) {
  if (!pageText || !pageText.items) return '';
  const rot = (planPage && planPage.rotate) || 0;
  let out = '';
  pageText.items.forEach(it => {
    const r = officePdfViewRotRect({ x: it.x, y: it.y, w: it.w, h: it.h }, rot);
    const hit = !(r.x > rect.x + rect.w || r.x + r.w < rect.x || r.y > rect.y + rect.h || r.y + r.h < rect.y);
    if (hit) out += it.str;
  });
  return out.trim().slice(0, 400);
}
