// =============================================================================
// LEON PDF — engine and storage core
// =============================================================================
// Loaded before office-pdf.jsx and office-pdf-tools.jsx. Everything here is the
// part of LEON PDF that touches bytes: where a PDF lives, how it is rendered,
// how text comes out of it, and how a new file is written. The UI files above
// it never touch pdfjsLib or PDFLib directly — they call these functions — so
// there is exactly one place that knows how the plumbing works.
//
// TWO LIBRARIES, DELIBERATELY. Rendering and writing are different problems and
// forcing one library to do both does both badly:
//   • pdf.js  (vendor/pdf.min.js)     — parse, render, extract text, search.
//   • pdf-lib (vendor/pdf-lib.min.js) — assemble, reorder, stamp, write.
// Both are vendored, both run entirely in this browser. There is no server, so
// there is no "PDF service": every operation below happens on this machine, and
// anything that genuinely needs a server is named as unavailable rather than
// faked.
//
// WHERE THE BYTES LIVE. Not in localStorage. The Hub's persisted state has about
// 13 MB in total and a single specification set is bigger than that on its own —
// writing a PDF there would take the entire app's state down with it. PDFs go in
// IndexedDB, which is sized for this, and the document record keeps only an id.

const OFFICE_PDF_DB = 'leon-pdf-assets';
const OFFICE_PDF_DB_VERSION = 1;
const OFFICE_PDF_STORE = 'assets';
const OFFICE_PDF_TEXT_STORE = 'text';

// ---------------------------------------------------------------------------
// Library loading
// ---------------------------------------------------------------------------
let officePdfLibsPromise = null;
function officePdfLibs() {
  // Resolves once with both libraries, or rejects with a message a user can act
  // on. Callers await this rather than assuming the globals exist, because a
  // vendor file that failed to load should produce one clear line, not a
  // TypeError from somewhere deep in a render loop.
  if (officePdfLibsPromise) return officePdfLibsPromise;
  officePdfLibsPromise = new Promise((resolve, reject) => {
    const pdfjsLib = window.pdfjsLib;
    const PDFLib = window.PDFLib;
    if (!pdfjsLib) { reject(new Error('The PDF viewer library (vendor/pdf.min.js) did not load.')); return; }
    if (!PDFLib) { reject(new Error('The PDF writer library (vendor/pdf-lib.min.js) did not load.')); return; }
    // pdf.js parses in a worker; without this it falls back to the main thread
    // and a 300-page document freezes the whole tab while it opens.
    try {
      pdfjsLib.GlobalWorkerOptions.workerSrc = 'vendor/pdf.worker.min.js';
    } catch (e) { /* already set */ }
    resolve({ pdfjsLib, PDFLib });
  });
  return officePdfLibsPromise;
}

// ---------------------------------------------------------------------------
// IndexedDB asset store
// ---------------------------------------------------------------------------
let officePdfDbPromise = null;
function officePdfDb() {
  if (officePdfDbPromise) return officePdfDbPromise;
  officePdfDbPromise = new Promise((resolve, reject) => {
    if (!window.indexedDB) { reject(new Error('This browser has no IndexedDB, so PDFs cannot be stored.')); return; }
    const req = window.indexedDB.open(OFFICE_PDF_DB, OFFICE_PDF_DB_VERSION);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(OFFICE_PDF_STORE)) db.createObjectStore(OFFICE_PDF_STORE, { keyPath: 'id' });
      if (!db.objectStoreNames.contains(OFFICE_PDF_TEXT_STORE)) db.createObjectStore(OFFICE_PDF_TEXT_STORE, { keyPath: 'id' });
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error || new Error('Could not open the PDF store.'));
  });
  return officePdfDbPromise;
}

function officePdfTx(store, mode, run) {
  return officePdfDb().then(db => new Promise((resolve, reject) => {
    const tx = db.transaction(store, mode);
    const os = tx.objectStore(store);
    let out;
    try { out = run(os); } catch (e) { reject(e); return; }
    tx.oncomplete = () => resolve(out && out.result !== undefined ? out.result : out);
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(tx.error || new Error('The PDF store transaction was aborted.'));
  }));
}

// An asset is an immutable blob of PDF (or image) bytes plus the metadata needed
// to describe it without opening it. Nothing ever overwrites an asset: an edit
// writes a NEW asset and the document points at it. That is what makes
// "the original is never touched" true rather than a promise.
function officePdfPutAsset(bytes, meta) {
  const id = (typeof uid === 'function' ? uid('asset') : 'asset-' + Date.now() + '-' + Math.floor(Math.random() * 1e6));
  const rec = { id, bytes, size: bytes && bytes.byteLength ? bytes.byteLength : (bytes && bytes.length) || 0,
                name: (meta && meta.name) || 'document.pdf', kind: (meta && meta.kind) || 'pdf',
                createdDate: typeof todayISO === 'function' ? todayISO() : '', ...(meta || {}) };
  return officePdfTx(OFFICE_PDF_STORE, 'readwrite', os => { os.put(rec); return id; }).then(() => id);
}
function officePdfGetAsset(id) {
  if (!id) return Promise.resolve(null);
  return officePdfDb().then(db => new Promise((resolve, reject) => {
    const tx = db.transaction(OFFICE_PDF_STORE, 'readonly');
    const req = tx.objectStore(OFFICE_PDF_STORE).get(id);
    req.onsuccess = () => resolve(req.result || null);
    req.onerror = () => reject(req.error);
  }));
}
function officePdfGetBytes(id) {
  return officePdfGetAsset(id).then(rec => {
    if (!rec) return null;
    // A stored ArrayBuffer is handed back as-is; pdf.js consumes (and detaches)
    // typed arrays, so every caller gets its own copy rather than a shared one.
    const b = rec.bytes;
    if (!b) return null;
    return b instanceof ArrayBuffer ? new Uint8Array(b.slice(0)) : new Uint8Array(b).slice();
  });
}
function officePdfDeleteAsset(id) {
  return officePdfTx(OFFICE_PDF_STORE, 'readwrite', os => { os.delete(id); return true; });
}
function officePdfListAssets() {
  return officePdfDb().then(db => new Promise((resolve, reject) => {
    const tx = db.transaction(OFFICE_PDF_STORE, 'readonly');
    const req = tx.objectStore(OFFICE_PDF_STORE).getAll();
    req.onsuccess = () => resolve((req.result || []).map(r => ({ ...r, bytes: undefined })));
    req.onerror = () => reject(req.error);
  }));
}
function officePdfStoreUsage() {
  return officePdfListAssets().then(list => ({
    count: list.length, bytes: list.reduce((s, r) => s + (r.size || 0), 0),
  }));
}

// Extracted text is stored apart from the binary on purpose: the original stays
// immutable, and a search index can be rebuilt or thrown away without ever
// rewriting the source document.
function officePdfPutText(id, pages) {
  return officePdfTx(OFFICE_PDF_TEXT_STORE, 'readwrite', os => { os.put({ id, pages, savedDate: typeof todayISO === 'function' ? todayISO() : '' }); return id; });
}
function officePdfGetText(id) {
  if (!id) return Promise.resolve(null);
  return officePdfDb().then(db => new Promise((resolve, reject) => {
    const tx = db.transaction(OFFICE_PDF_TEXT_STORE, 'readonly');
    const req = tx.objectStore(OFFICE_PDF_TEXT_STORE).get(id);
    req.onsuccess = () => resolve(req.result ? req.result.pages : null);
    req.onerror = () => reject(req.error);
  }));
}

// ---------------------------------------------------------------------------
// Reading files in
// ---------------------------------------------------------------------------
function officePdfReadFile(file) {
  return new Promise((resolve, reject) => {
    const r = new FileReader();
    r.onload = () => resolve(new Uint8Array(r.result));
    r.onerror = () => reject(r.error || new Error('Could not read ' + (file && file.name)));
    r.readAsArrayBuffer(file);
  });
}
function officePdfImportFile(file) {
  // Import = store the bytes exactly as they arrived, then look inside a copy.
  // The page count and title come from the copy; the stored asset is untouched.
  return officePdfReadFile(file).then(bytes =>
    officePdfPutAsset(bytes.buffer, { name: file.name, kind: 'pdf', mime: file.type || 'application/pdf' })
      .then(assetId => officePdfDocument(assetId).then(pdf => ({
        assetId, name: file.name, size: bytes.byteLength, pageCount: pdf.numPages,
      })))
  );
}

// ---------------------------------------------------------------------------
// pdf.js document handles
// ---------------------------------------------------------------------------
const officePdfDocCache = new Map();
function officePdfDocument(assetId) {
  // Opening a document is expensive and every panel wants one, so handles are
  // cached by asset id. Assets are immutable, so a cached handle can never go
  // stale — that is the payoff for never overwriting bytes.
  if (officePdfDocCache.has(assetId)) return officePdfDocCache.get(assetId);
  const p = officePdfLibs().then(({ pdfjsLib }) =>
    officePdfGetBytes(assetId).then(bytes => {
      if (!bytes) throw new Error('That PDF is no longer in the document store.');
      return pdfjsLib.getDocument({ data: bytes, isEvalSupported: false }).promise;
    })
  );
  officePdfDocCache.set(assetId, p);
  p.catch(() => officePdfDocCache.delete(assetId));
  return p;
}
function officePdfForget(assetId) {
  const h = officePdfDocCache.get(assetId);
  officePdfDocCache.delete(assetId);
  if (h) h.then(d => { try { d.destroy(); } catch (e) {} }).catch(() => {});
}

// ---------------------------------------------------------------------------
// A cover image from a file that is NOT in the PDF store
// ---------------------------------------------------------------------------
// The document store is for Leon PDF's own assets, held in IndexedDB and
// addressed by asset id. A drawing set is a different thing: a file attached
// through the ordinary FileField, carried as a data URL on the record. So this
// takes the URL rather than an asset id, and returns page one as an image.
//
// An attachment that is already a picture comes straight back — a drawing set
// is often a JPEG or a PNG, and re-rendering one through a PDF engine to get
// the same pixels would be silly.
function officePdfCoverImage(url, opts) {
  const o = opts || {};
  const width = o.width || 1400;
  if (!url) return Promise.resolve(null);
  const looksImage = /^data:image\//i.test(url) || /\.(png|jpe?g|gif|webp)(\?|$)/i.test(url);
  if (looksImage) return Promise.resolve({ url, fromPdf: false });
  const looksPdf = /^data:application\/pdf/i.test(url) || /\.pdf(\?|$)/i.test(url);
  if (!looksPdf) return Promise.resolve(null);

  return officePdfLibs().then(({ pdfjsLib }) => {
    // A data URL has to be decoded to bytes; a plain path pdf.js fetches itself.
    let src;
    if (/^data:/i.test(url)) {
      const b64 = url.slice(url.indexOf(',') + 1);
      const bin = atob(b64);
      const bytes = new Uint8Array(bin.length);
      for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
      src = { data: bytes, isEvalSupported: false };
    } else {
      src = { url, isEvalSupported: false };
    }
    return pdfjsLib.getDocument(src).promise.then(pdf => pdf.getPage(1).then(page => {
      const base = page.getViewport({ scale: 1, rotation: page.rotate || 0 });
      const scale = width / base.width;
      const viewport = page.getViewport({ scale, rotation: page.rotate || 0 });
      const canvas = document.createElement('canvas');
      canvas.width = Math.max(1, Math.round(viewport.width));
      canvas.height = Math.max(1, Math.round(viewport.height));
      const cx = canvas.getContext('2d');
      // A drawing is line work on white. Painting the ground first means a page
      // with a transparent background does not come out black on the slide.
      cx.fillStyle = '#FFFFFF';
      cx.fillRect(0, 0, canvas.width, canvas.height);
      return page.render({ canvasContext: cx, viewport }).promise.then(() => ({
        url: canvas.toDataURL('image/jpeg', 0.82),
        fromPdf: true, pages: pdf.numPages,
        width: canvas.width, height: canvas.height,
      }));
    }));
  });
}

// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
// Renders one page into a canvas at a target CSS width. `rotate` is the LEON
// layer's extra rotation, added to whatever the page itself declares — the
// source page is never modified to turn it.
function officePdfRenderPage(assetId, pageIndex, opts) {
  const o = opts || {};
  return officePdfDocument(assetId).then(pdf => pdf.getPage(pageIndex + 1).then(page => {
    const rotation = ((page.rotate || 0) + (o.rotate || 0) + 360) % 360;
    const base = page.getViewport({ scale: 1, rotation });
    const dpr = o.dpr || (window.devicePixelRatio || 1);
    const scale = o.width ? (o.width / base.width) : (o.scale || 1);
    const viewport = page.getViewport({ scale, rotation });
    const canvas = document.createElement('canvas');
    canvas.width = Math.max(1, Math.floor(viewport.width * dpr));
    canvas.height = Math.max(1, Math.floor(viewport.height * dpr));
    canvas.style.width = Math.floor(viewport.width) + 'px';
    canvas.style.height = Math.floor(viewport.height) + 'px';
    const cx = canvas.getContext('2d');
    cx.setTransform(dpr, 0, 0, dpr, 0, 0);
    const task = page.render({ canvasContext: cx, viewport });
    return task.promise.then(() => ({
      canvas, width: viewport.width, height: viewport.height,
      pageWidth: base.width, pageHeight: base.height, rotation,
    }));
  }));
}
function officePdfPageSize(assetId, pageIndex) {
  return officePdfDocument(assetId).then(pdf => pdf.getPage(pageIndex + 1).then(page => {
    const v = page.getViewport({ scale: 1, rotation: page.rotate || 0 });
    return { width: v.width, height: v.height, rotation: page.rotate || 0 };
  }));
}

// ---------------------------------------------------------------------------
// Text extraction and search
// ---------------------------------------------------------------------------
// Returns text items in PAGE-PERCENTAGE coordinates, because that is the only
// coordinate system that survives zooming, rotating and a different screen. A
// highlight recorded at 12% across lands on the same word at any magnification.
function officePdfPageText(assetId, pageIndex) {
  return officePdfDocument(assetId).then(pdf => pdf.getPage(pageIndex + 1).then(page => {
    const viewport = page.getViewport({ scale: 1, rotation: 0 });
    return page.getTextContent().then(content => {
      const items = [];
      let text = '';
      (content.items || []).forEach(it => {
        const s = it.str || '';
        const t = it.transform || [1, 0, 0, 1, 0, 0];
        const h = Math.abs(t[3]) || Math.abs(it.height) || 10;
        const w = it.width || 0;
        const x = t[4], yBottom = t[5];
        items.push({
          str: s, start: text.length,
          x: x / viewport.width, y: (viewport.height - yBottom - h) / viewport.height,
          w: w / viewport.width, h: h / viewport.height,
        });
        text += s;
        if (it.hasEOL) text += '\n'; else text += '';
      });
      return { page: pageIndex, text, items, width: viewport.width, height: viewport.height };
    });
  }));
}
function officePdfExtractText(assetId, onProgress) {
  return officePdfDocument(assetId).then(pdf => {
    const pages = [];
    let chain = Promise.resolve();
    for (let i = 0; i < pdf.numPages; i++) {
      chain = chain.then(() => officePdfPageText(assetId, i).then(p => {
        pages.push(p);
        if (onProgress) onProgress(i + 1, pdf.numPages);
      }));
    }
    return chain.then(() => pages);
  });
}
// A page with no text objects at all is a scan — an image of words, not words.
// Knowing that is what tells the OCR panel whether it has anything to offer.
function officePdfIsScanned(pages) {
  if (!pages || !pages.length) return false;
  const empty = pages.filter(p => !(p.text || '').trim()).length;
  return empty / pages.length > 0.8;
}
function officePdfSearch(pages, query, opts) {
  const o = opts || {};
  const q = (query || '').trim();
  if (!q) return [];
  const flags = o.caseSensitive ? 'g' : 'gi';
  const esc = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const pattern = o.wholeWord ? '\\b' + esc + '\\b' : esc;
  const out = [];
  (pages || []).forEach(p => {
    let m; const re = new RegExp(pattern, flags);
    while ((m = re.exec(p.text || '')) !== null) {
      if (m.index === re.lastIndex) re.lastIndex++;
      out.push({ page: p.page, index: m.index, length: m[0].length, match: m[0],
                 rects: officePdfRectsFor(p, m.index, m[0].length),
                 context: (p.text || '').slice(Math.max(0, m.index - 60), m.index + m[0].length + 60).replace(/\s+/g, ' ').trim() });
      if (out.length > 5000) return;
    }
  });
  return out;
}
// Maps a character range in a page's text back to the boxes it occupies. A match
// can straddle several text items, so this returns one rect per item touched
// rather than a single box that would swallow the whole line.
function officePdfRectsFor(pageText, start, length) {
  const end = start + length;
  const rects = [];
  (pageText.items || []).forEach(it => {
    const s = it.start, e = it.start + (it.str || '').length;
    if (e <= start || s >= end) return;
    const len = Math.max(1, (it.str || '').length);
    const from = Math.max(0, start - s), to = Math.min(len, end - s);
    rects.push({ x: it.x + it.w * (from / len), y: it.y,
                 w: it.w * ((to - from) / len), h: it.h });
  });
  return rects;
}

// ---------------------------------------------------------------------------
// Writing PDFs
// ---------------------------------------------------------------------------
// Assembles a new PDF from a page plan — [{ assetId, index, rotate }] — copying
// pages out of the source assets. This is what "reorder / delete / insert /
// combine / extract / split" all come down to: a different plan, a new file, the
// originals untouched.
function officePdfAssemble(plan, opts) {
  const o = opts || {};
  return officePdfLibs().then(({ PDFLib }) => {
    const wanted = [];
    plan.forEach(p => { if (!p.hidden && wanted.indexOf(p.assetId) === -1) wanted.push(p.assetId); });
    return Promise.all(wanted.map(id => officePdfGetBytes(id).then(b => PDFLib.PDFDocument.load(b, { ignoreEncryption: true }))))
      .then(loaded => {
        const byAsset = {}; wanted.forEach((id, i) => { byAsset[id] = loaded[i]; });
        return PDFLib.PDFDocument.create().then(out => {
          const steps = plan.filter(p => !p.hidden);
          let chain = Promise.resolve();
          steps.forEach((p, n) => {
            chain = chain.then(() => out.copyPages(byAsset[p.assetId], [p.index]).then(([page]) => {
              if (p.rotate) page.setRotation(PDFLib.degrees(((page.getRotation().angle || 0) + p.rotate) % 360));
              if (p.crop && p.crop.w && p.crop.h) {
                // Crop is stored in page percentages; the box PDF wants is in
                // points from the bottom-left, hence the y flip.
                const { width, height } = page.getSize();
                page.setCropBox(p.crop.x * width, (1 - p.crop.y - p.crop.h) * height,
                                p.crop.w * width, p.crop.h * height);
              }
              out.addPage(page);
              if (o.onProgress) o.onProgress(n + 1, steps.length);
            }));
          });
          return chain.then(() => {
            if (o.title || o.subject || o.author || o.keywords) {
              if (o.title) out.setTitle(o.title);
              if (o.subject) out.setSubject(o.subject);
              if (o.author) out.setAuthor(o.author);
              if (o.keywords) out.setKeywords(Array.isArray(o.keywords) ? o.keywords : [o.keywords]);
              out.setProducer('LEON PDF'); out.setCreator('LEON Operations Hub');
            }
            return out;
          });
        });
      });
  });
}
function officePdfSaveDoc(pdfDoc) { return pdfDoc.save({ useObjectStreams: true }); }

// Builds a PDF whose pages are IMAGES of the given source pages. This is the
// honest way to do two things in a browser: apply a real redaction (the text is
// gone from the file because there is no text in the file), and flatten a marked
// up document. The trade-off is real and must be stated wherever it is offered:
// the result is no longer selectable or searchable text.
function officePdfRasterize(plan, opts) {
  const o = opts || {};
  const dpi = o.dpi || 150;
  const quality = o.quality || 0.9;
  return officePdfLibs().then(({ PDFLib }) => PDFLib.PDFDocument.create().then(out => {
    const steps = plan.filter(p => !p.hidden);
    let chain = Promise.resolve();
    steps.forEach((p, n) => {
      chain = chain.then(() =>
        officePdfRenderPage(p.assetId, p.index, { scale: dpi / 72, rotate: p.rotate || 0, dpr: 1 })
          .then(r => {
            if (o.paint) o.paint(r.canvas.getContext('2d'), r, p, n);
            const data = r.canvas.toDataURL('image/jpeg', quality);
            return out.embedJpg(data).then(img => {
              const page = out.addPage([r.pageWidth, r.pageHeight]);
              page.drawImage(img, { x: 0, y: 0, width: r.pageWidth, height: r.pageHeight });
              if (o.onProgress) o.onProgress(n + 1, steps.length);
            });
          }));
    });
    return chain.then(() => out);
  }));
}

// Images in, one PDF out. Each image gets a page its own size, so a set of
// photographs does not end up letterboxed on Letter paper.
function officePdfFromImages(files, opts) {
  const o = opts || {};
  return officePdfLibs().then(({ PDFLib }) => PDFLib.PDFDocument.create().then(out => {
    let chain = Promise.resolve();
    Array.from(files).forEach((f, n) => {
      chain = chain.then(() => officePdfReadFile(f).then(bytes => {
        const isPng = /\.png$/i.test(f.name) || f.type === 'image/png';
        return (isPng ? out.embedPng(bytes) : out.embedJpg(bytes)).then(img => {
          const fit = o.pageSize === 'Letter' ? [612, 792] : o.pageSize === 'A4' ? [595.28, 841.89] : null;
          if (fit) {
            const page = out.addPage(fit);
            const s = Math.min((fit[0] - 36) / img.width, (fit[1] - 36) / img.height);
            page.drawImage(img, { x: (fit[0] - img.width * s) / 2, y: (fit[1] - img.height * s) / 2,
                                  width: img.width * s, height: img.height * s });
          } else {
            const page = out.addPage([img.width, img.height]);
            page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height });
          }
          if (o.onProgress) o.onProgress(n + 1, files.length);
        });
      }));
    });
    return chain.then(() => out);
  }));
}

// ---------------------------------------------------------------------------
// Handing a finished file back to the user
// ---------------------------------------------------------------------------
function officePdfDownload(bytes, filename) {
  const blob = new Blob([bytes], { type: 'application/pdf' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url; a.download = filename || 'leon-document.pdf';
  document.body.appendChild(a); a.click(); a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 4000);
}
function officePdfPrint(bytes) {
  // Printing goes through the browser's own dialog against a blob URL. A page
  // cannot drive a printer itself, and pretending otherwise would just be a
  // button that does nothing.
  const blob = new Blob([bytes], { type: 'application/pdf' });
  const url = URL.createObjectURL(blob);
  const frame = document.createElement('iframe');
  frame.style.cssText = 'position:fixed;right:0;bottom:0;width:1px;height:1px;border:0;opacity:0';
  frame.src = url;
  frame.onload = () => { try { frame.contentWindow.focus(); frame.contentWindow.print(); } catch (e) { window.open(url, '_blank'); } };
  document.body.appendChild(frame);
  setTimeout(() => { frame.remove(); URL.revokeObjectURL(url); }, 60000);
}
function officePdfBytesLabel(n) {
  if (!n) return '—';
  if (n < 1024) return n + ' B';
  if (n < 1048576) return (n / 1024).toFixed(0) + ' KB';
  return (n / 1048576).toFixed(1) + ' MB';
}
