// ============================================================================
// LEON Operations Hub — business logic (delay cascade, financials, RBAC helpers)
// ============================================================================

function cloneDeep(obj) { return JSON.parse(JSON.stringify(obj)); }

// Shared list-sorting helper for the app's directory-style list views
// (Accounts, Contacts, Vendors, Team, etc.) — each view defines its own
// {key,label} SORT_OPTIONS array and a matching comparator map, keyed by the
// same option keys, and calls sortList(list, sortKey, comparators).
function sortList(list, sortKey, comparators) {
  const cmp = comparators[sortKey];
  return cmp ? list.slice().sort(cmp) : list;
}
function textAsc(a, b) { return (a || '').localeCompare(b || ''); }
function textDesc(a, b) { return (b || '').localeCompare(a || ''); }
function dateAsc(a, b) { return (a || '').localeCompare(b || ''); }
function dateDesc(a, b) { return (b || '').localeCompare(a || ''); }

// Parses the first sheet of an uploaded .xlsx workbook into raw PI material
// rows (§ PI material list import request) — SheetJS (window.XLSX, loaded
// via index.html) is this project's first external dependency, added
// specifically because the client asked for real Excel files, not just
// CSV. Column matching is case-insensitive and tolerant of a few common
// header spellings; anything it can't confidently map lands in
// `description` so nothing silently disappears. This only returns raw rows
// — matching a row's description against the Material Library (to fill in
// materialId) happens in app.jsx, where ctx.materialLibrary is available.
// Real vendor exports rarely match a clean single-header-row CSV shape —
// the reference file this was built against (a cabinet BOM/quotation) has
// a two-row header (a merged category row, e.g. "DIMENSION (inch)", over a
// sub-label row, e.g. "W"/"H"/"D"), an item CODE column separate from the
// description, and duplicate-looking Qty/Price column PAIRS from revision
// history — the rightmost pair is the current, agreed value.
function parseExcelMaterialList(file) {
  return new Promise((resolve, reject) => {
    if (!window.XLSX) { reject(new Error('Excel import library did not load.')); return; }
    const reader = new FileReader();
    reader.onerror = () => reject(new Error('Could not read the file.'));
    reader.onload = () => {
      try {
        const workbook = XLSX.read(reader.result, { type: 'array' });
        const sheet = workbook.Sheets[workbook.SheetNames[0]];
        const raw = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' });
        if (!raw.length) { resolve([]); return; }

        // Squash to letters+digits only (so "Q.TY\n(pc)" / "PRICE'S FOB
        // (USD)" still read as containing "qty" / "price") for substring
        // matching; keep a trimmed-lowercase exact form for single-letter
        // W/H/D columns, where substring matching would false-positive
        // inside words like "New".
        const squash = v => String(v ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
        const exact = v => String(v ?? '').trim().toLowerCase();

        // Grow a contiguous header BLOCK from row 0 — rather than picking a
        // single "best" row — so a category row ("DIMENSION (inch)") sitting
        // above its own sub-label row ("W"/"H"/"D") both end up inside the
        // block: keep extending headerRowIdx while a row still has any
        // recognizable-column signal, and stop at the first row that has
        // none (that's the first real data row).
        const KEYWORDS = ['description', 'material', 'item', 'name', 'qty', 'quantity', 'cost', 'price', 'dimension', 'code', 'sku', 'thickness', 'thk', 'width', 'height', 'depth', 'length', 'size'];
        const hasHeaderSignal = row => row.some(c => {
          const s = squash(c);
          if (!s) return false;
          if (['w', 'h', 'd'].includes(exact(c))) return true;
          return KEYWORDS.some(k => s.includes(k));
        });
        let headerRowIdx = 0;
        for (let i = 0; i < Math.min(6, raw.length); i++) {
          if (hasHeaderSignal(raw[i])) headerRowIdx = i;
          else if (i > 0) break;
        }

        // Column resolution searches every row from 0 up to the chosen
        // header row (not just that one row), unioned by column index —
        // this is what lets a value split across a two-row header (the
        // category label on one row, "W"/"H"/"D"/"Qty"/"Price" on the next)
        // still resolve correctly without needing to merge the rows.
        const headerRows = raw.slice(0, headerRowIdx + 1);
        const numCols = headerRows.reduce((m, r) => Math.max(m, r.length), 0);
        function colsWhere(test) {
          const cols = [];
          for (let c = 0; c < numCols; c++) if (headerRows.some(r => test(r[c]))) cols.push(c);
          return cols;
        }
        function lastOr(cols, fallback) { return cols.length ? cols[cols.length - 1] : fallback; }
        function firstOr(cols, fallback) { return cols.length ? cols[0] : fallback; }

        // Description: prefer an exact "Description" column; only fall
        // back to a generic Material/Item/Name column (which can otherwise
        // collide with an item-code column like "Old Item Name") if none
        // exists.
        let descCol = firstOr(colsWhere(c => exact(c) === 'description'), -1);
        if (descCol < 0) descCol = firstOr(colsWhere(c => ['material', 'item', 'name'].includes(exact(c))), -1);
        if (descCol < 0) descCol = firstOr(colsWhere(c => squash(c).includes('description') || squash(c).includes('material')), -1);

        const codeCols = colsWhere(c => { const s = squash(c); return ['itemcode', 'sku', 'code', 'productcode', 'newitemname', 'olditemname', 'itemname'].some(k => s.includes(k)); });
        const newCodeCols = codeCols.filter(i => headerRows.some(r => squash(r[i]).includes('new')));
        const codeCol = newCodeCols.length ? newCodeCols[newCodeCols.length - 1] : lastOr(codeCols, -1);

        const qtyCol = lastOr(colsWhere(c => { const s = squash(c); return s.includes('qty') || s.includes('quantity'); }), -1);
        const costCol = lastOr(colsWhere(c => { const s = squash(c); return s.includes('unitcost') || s.includes('cost') || s.includes('price'); }), -1);
        const unitCol = firstOr(colsWhere(c => ['unit', 'uom', 'unit of measure'].includes(exact(c))), -1);

        // Item NUMBER (the line/sequence number on the sheet) is distinct from
        // the item CODE (the manufacturer's identifier) — vendor sheets
        // routinely carry both, and conflating them loses the ability to
        // refer back to a line by its number on the original document.
        const itemNoCol = firstOr(colsWhere(c => { const s = squash(c); const e = exact(c); return e === '#' || e === 'no' || e === 'no.' || e === 'item' || s === 'itemno' || s === 'itemnumber' || s === 'lineno' || s === 'line' || s === 'seq'; }), -1);
        // A separate human-readable NAME column, where the sheet has one in
        // addition to a long description.
        const itemNameCol = firstOr(colsWhere(c => { const s = squash(c); return s === 'itemname' || s === 'name' || s === 'productname' || s === 'material'; }), -1);
        // Thickness is called out separately from W/H/D because it's the
        // dimension that drives material selection for slabs, tile and panels.
        const thickCol = firstOr(colsWhere(c => { const s = squash(c); const e = exact(c); return s.includes('thickness') || s === 'thk' || e === 't'; }), -1);

        const wCol = firstOr(colsWhere(c => exact(c) === 'w'), -1);
        const hCol = firstOr(colsWhere(c => exact(c) === 'h'), -1);
        const dCol = firstOr(colsWhere(c => exact(c) === 'd'), -1);
        const dimsCol = (wCol < 0 || hCol < 0 || dCol < 0) ? firstOr(colsWhere(c => { const s = squash(c); return s.includes('dimension') || s.includes('dims') || s.includes('size'); }), -1) : -1;

        const cell = (row, idx) => (idx >= 0 && row[idx] !== undefined && row[idx] !== '' ? row[idx] : '');
        const dataRows = raw.slice(headerRowIdx + 1);
        const parsed = dataRows.map(row => {
          const dimensions = wCol >= 0 && hCol >= 0 && dCol >= 0
            ? [cell(row, wCol), cell(row, hCol), cell(row, dCol)].filter(v => v !== '').join(' x ')
            : String(cell(row, dimsCol)).trim();
          const rawDesc = String(cell(row, descCol)).trim();
          const rawName = String(cell(row, itemNameCol)).trim();
          return {
            // Sheets that carry a Name but no Description would otherwise
            // import with a blank description, leaving every line unlabelled
            // in the UI — fall back to the name, keeping both fields intact.
            description: rawDesc || rawName,
            itemNo: String(cell(row, itemNoCol)).trim(),
            itemName: rawName,
            itemCode: String(cell(row, codeCol)).trim(),
            quantity: Number(cell(row, qtyCol)) || 0,
            unit: String(cell(row, unitCol) || 'Units').trim() || 'Units',
            unitCost: Number(cell(row, costCol)) || 0,
            // Individual dimensions are kept alongside the joined string:
            // `dimensions` stays the display value every existing screen
            // already reads, while W/H/D/thickness are available separately
            // for filtering and for matching against the Material Library.
            width: String(cell(row, wCol)).trim(),
            height: String(cell(row, hCol)).trim(),
            depth: String(cell(row, dCol)).trim(),
            thickness: String(cell(row, thickCol)).trim(),
            dimensions,
          };
        // A row counts as real if it names the item ANY way — description,
        // name, or code. Requiring `description` alone silently dropped rows
        // on sheets that only carry a code and a name.
        }).filter(r => r.description || r.itemName || r.itemCode);
        resolve(parsed);
      } catch (e) {
        reject(new Error('Could not parse this file as an Excel workbook.'));
      }
    };
    reader.readAsArrayBuffer(file);
  });
}

// ---------------------------------------------------------------------------
// Cash flow engine — turns existing records into planned/actual cash events
// ---------------------------------------------------------------------------

// Contract value a percentage-based payment term is a percentage OF: the
// original contract plus approved change orders, since a CO changes what the
// client owes.
function contractValueForCash(project) {
  const base = Number(project.originalContractValue) || 0;
  const cos = (project.changeOrders || [])
    .filter(co => co.status === 'Approved')
    .reduce((n, co) => n + (Number(co.amount) || 0), 0);
  return base + cos;
}

// The forecast date for a payment term. A manual expectedDate always wins;
// otherwise it is derived from the stage its trigger names — the LATEST such
// stage across scopes, because the client pays once the whole trigger is met,
// not when the first scope reaches it.
function paymentTermDate(project, term) {
  if (term.expectedDate) return { date: term.expectedDate, source: 'manual' };
  const stageKey = PAYMENT_TRIGGER_STAGE[term.trigger];
  if (!stageKey) return { date: null, source: 'none' };
  let latest = null;
  (project.scopes || []).forEach(sc => {
    (sc.stages || []).forEach(st => {
      if (st.key !== stageKey) return;
      const d = st.actualCompletion || st.plannedDue;
      if (d && (!latest || d > latest)) latest = d;
    });
  });
  return { date: latest, source: latest ? 'derived' : 'none' };
}

function paymentTermAmount(project, term) {
  if (term.amountOverride != null) return Number(term.amountOverride) || 0;
  return contractValueForCash(project) * ((Number(term.pct) || 0) / 100);
}

// Expands a recurring manual entry into occurrences inside [from, to].
// Occurrences are generated on read rather than written as rows, so editing
// the entry changes every future occurrence and rewrites no history.
function expandCashEntry(entry, from, to) {
  const out = [];
  if (!entry || entry.active === false || !entry.date) return out;
  const step = { Weekly: 7, Monthly: 1, Quarterly: 3, Annually: 12 }[entry.recurrence];
  const stop = entry.endDate && entry.endDate < to ? entry.endDate : to;
  if (entry.recurrence === 'One-off' || !step) {
    if (entry.date >= from && entry.date <= to) out.push(entry.date);
    return out;
  }
  let d = entry.date;
  let guard = 0;
  while (d <= stop && guard++ < 600) {
    if (d >= from) out.push(d);
    d = entry.recurrence === 'Weekly' ? addDays(d, 7) : addMonths(d, step);
  }
  return out;
}

// Which statement a charge on `date` falls into, and when that bill is due.
// A charge after the statement day rolls into the NEXT month's statement.
function cardStatementFor(card, date) {
  const d = fromISO(date);
  const day = d.getDate();
  const closeDay = Math.min(card.statementDay || 25, 28);
  // Statement closing date this charge belongs to.
  let close = new Date(d.getFullYear(), d.getMonth(), closeDay);
  if (day > closeDay) close = new Date(d.getFullYear(), d.getMonth() + 1, closeDay);
  // Bill is due on dueDay of the month after closing (or same month if later).
  const dueDay = Math.min(card.dueDay || 15, 28);
  let due = new Date(close.getFullYear(), close.getMonth() + (dueDay > closeDay ? 0 : 1), dueDay);
  return { closeDate: toISO(close), dueDate: toISO(due) };
}

// The window of charges a statement covers: the day after the previous close,
// through the close itself. Used to label a statement group with its period.
function cardStatementRange(card, closeDate) {
  const c = fromISO(closeDate);
  const prevClose = new Date(c.getFullYear(), c.getMonth() - 1, c.getDate());
  const start = new Date(prevClose.getFullYear(), prevClose.getMonth(), prevClose.getDate() + 1);
  return { startDate: toISO(start), closeDate };
}

// The single list every cash view reads. `projects` may be pre-filtered by the
// caller (e.g. to one project); `entries` are the manual company-overhead rows.
function buildCashEvents(projects, entries, from, to, cards) {
  const events = [];
  const push = e => { if (e.date && e.date >= from && e.date <= to) events.push(e); };
  // Charges routed to a company card don't leave cash on their own date — they
  // accumulate into that card's statement, which becomes ONE cash-out on the
  // bill's due date. Collected here, emitted at the end.
  const cardList = cards || [];
  const statements = {};   // "<cardId>|<dueDate>" -> { card, dueDate, closeDate, total, items }
  function chargeToCard(cardId, date, amount, label) {
    const card = cardList.find(c => c.id === cardId);
    if (!card) return false;
    const { closeDate, dueDate } = cardStatementFor(card, date);
    const key = `${card.id}|${dueDate}`;
    if (!statements[key]) statements[key] = { card, dueDate, closeDate, total: 0, items: [] };
    statements[key].total += amount;
    statements[key].items.push({ label, amount, date });
    return true;
  }

  // A bank-held receipt lands on the calendar at ZERO on the day the client
  // paid — the fact is recorded, the cash isn't there yet — and each release
  // phase becomes its own money-in event on the day the bank lets it go.
  function pushHeldReceipt(p, rec, label, detail, received, receivedDate, sourceType, sourceId) {
    push({
      id: `${sourceType}-${sourceId}`, direction: 'in', kind: 'Client Payment',
      projectId: p.id, projectName: p.name, projectNumber: p.projectNumber,
      label, detail: [detail, 'Paid — held by bank'].filter(Boolean).join(' · '),
      amount: 0, heldAmount: received, date: receivedDate,
      actual: true, dateSource: 'actual', sourceType, sourceId, bankHeld: true,
    });
    (rec.bankHold.releases || []).forEach((rel, i) => {
      const done = !!rel.releasedDate;
      push({
        id: `rel-${rel.id}`, direction: 'in', kind: 'Bank Release',
        projectId: p.id, projectName: p.name, projectNumber: p.projectNumber,
        label: `${label} — ${rel.name || `Release ${i + 1}`}`,
        detail: `Released by ${rec.bankHold.bankName || 'the bank'}`,
        amount: done && rel.releasedAmount != null ? Number(rel.releasedAmount) : Number(rel.amount) || 0,
        date: done ? rel.releasedDate : rel.plannedDate,
        actual: done, dateSource: done ? 'actual' : 'manual',
        sourceType: 'bankRelease', sourceId: rel.id,
        parentType: sourceType, parentId: sourceId,
      });
    });
  }

  (projects || []).forEach(p => {
    // ---- money IN: client payment terms
    (p.paymentTerms || []).forEach(t => {
      const paid = t.status === 'Paid' || !!t.receivedDate;
      const { date, source } = paymentTermDate(p, t);
      const received = t.receivedAmount != null ? Number(t.receivedAmount) : paymentTermAmount(p, t);
      if (paid && isBankHeld(t)) {
        pushHeldReceipt(p, t, t.label || 'Payment', t.trigger || '', received, t.receivedDate || date, 'paymentTerm', t.id);
        return;
      }
      push({
        id: `pt-${t.id}`, direction: 'in', kind: 'Client Payment',
        projectId: p.id, projectName: p.name, projectNumber: p.projectNumber,
        label: t.label || 'Payment', detail: t.trigger || '',
        amount: paid ? received : paymentTermAmount(p, t),
        date: paid ? (t.receivedDate || date) : date,
        actual: paid, dateSource: paid ? 'actual' : source,
        sourceType: 'paymentTerm', sourceId: t.id,
        postponed: !paid && t.expectedDateOriginal && t.expectedDateOriginal !== t.expectedDate ? t.expectedDateOriginal : null,
      });
    });

    // ---- money IN: AIA payment requisitions, once actually received.
    // Only RECEIVED requisitions produce events: an outstanding requisition is
    // already forecast by the payment terms above, and a project can bill both
    // ways, so forecasting them here would double-count the same money.
    (p.paymentRequisitions || []).forEach(r => {
      if (!r.receivedDate) return;
      const received = r.receivedAmount != null ? Number(r.receivedAmount) : Number(r.amount) || 0;
      const label = `Requisition R${r.revision}${r.reference ? ` — ${r.reference}` : ''}`;
      if (isBankHeld(r)) {
        pushHeldReceipt(p, r, label, r.type || 'AIA Application', received, r.receivedDate, 'requisition', r.id);
        return;
      }
      push({
        id: `req-${r.id}`, direction: 'in', kind: 'Client Payment',
        projectId: p.id, projectName: p.name, projectNumber: p.projectNumber,
        label, detail: r.type || 'AIA Application',
        amount: received, date: r.receivedDate,
        actual: true, dateSource: 'actual', sourceType: 'requisition', sourceId: r.id,
      });
    });

    // ---- money OUT: AP invoices (vendor / freight / subcontractor / misc)
    (p.apInvoices || []).filter(apInvoiceLive).forEach(inv => {
      const payments = inv.payments || [];
      if (payments.length) {
        // Each real payment is its own locked event.
        payments.forEach((pay, i) => {
          const amt = Number(pay.amount) || 0;
          // Paid by card => the cash leaves when the card bill is paid.
          if (pay.creditCardId && chargeToCard(pay.creditCardId, pay.date, amt, `${inv.vendorName || inv.invoiceNumber} — ${inv.invoiceNumber}`)) return;
          push({
            id: `appay-${inv.id}-${i}`, direction: 'out', kind: inv.partyType || 'Payable',
            projectId: p.id, projectName: p.name, projectNumber: p.projectNumber,
            label: inv.vendorName || inv.invoiceNumber, detail: `Invoice ${inv.invoiceNumber}`,
            amount: amt, date: pay.date,
            actual: true, dateSource: 'actual', sourceType: 'apInvoice', sourceId: inv.id,
          });
        });
      }
      // Anything still outstanding stays a forecast on its due date.
      const paidTotal = payments.reduce((n, x) => n + (Number(x.amount) || 0), 0);
      const outstanding = (Number(inv.amount) || 0) - paidTotal;
      if (outstanding > 0.005 && !['Cancelled', 'Rejected'].includes(inv.approvalStatus)) {
        push({
          id: `ap-${inv.id}`, direction: 'out', kind: inv.partyType || 'Payable',
          projectId: p.id, projectName: p.name, projectNumber: p.projectNumber,
          label: inv.vendorName || inv.invoiceNumber, detail: `Invoice ${inv.invoiceNumber}`,
          amount: outstanding, date: inv.dueDate || inv.invoiceDate,
          actual: false, dateSource: 'manual', sourceType: 'apInvoice', sourceId: inv.id,
          postponed: inv.dueDateOriginal && inv.dueDateOriginal !== inv.dueDate ? inv.dueDateOriginal : null,
          blocked: inv.approvalStatus === 'Pending Sales Approval' ? 'Pending sales approval' : null,
        });
      }
    });
  });

  // ---- company overhead and other non-job money
  (entries || []).forEach(entry => {
    expandCashEntry(entry, from, to).forEach(d => {
      // A credit routed to a card (refund, statement payment) belongs ON that
      // statement reducing the bill, not as its own money-in on the day it
      // posted — so it goes to the card as a negative rather than falling
      // through to the calendar on its own date.
      if (entry.creditCardId
          && chargeToCard(entry.creditCardId, d, (entry.direction === 'in' ? -1 : 1) * (Number(entry.amount) || 0), entry.label)) return;
      push({
      id: `cash-${entry.id}-${d}`, direction: entry.direction,
      kind: entry.category, projectId: entry.projectId || null,
      projectName: entry.projectId ? '' : 'Company', projectNumber: '',
      label: entry.label, detail: entry.recurrence === 'One-off' ? '' : entry.recurrence,
      amount: Number(entry.amount) || 0, date: d,
      actual: false, dateSource: 'manual', sourceType: 'cashEntry', sourceId: entry.id,
      });
    });
  });

  // One cash-out per card statement.
  Object.values(statements).forEach(st => push({
    id: `ccbill-${st.card.id}-${st.dueDate}`, direction: st.total < 0 ? 'in' : 'out', kind: 'Credit Card',
    projectId: null, projectName: 'Company', projectNumber: '',
    label: `${st.card.name}${st.card.last4 ? ` ••${st.card.last4}` : ''} statement`,
    detail: `${st.items.length} charge${st.items.length === 1 ? '' : 's'} · closes ${st.closeDate}`,
    amount: Math.abs(st.total), date: st.dueDate,
    actual: false, dateSource: 'manual', sourceType: 'cardStatement', sourceId: st.card.id,
    cardItems: st.items,
  }));

  events.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
  return events;
}

// How close a planned cash event is to its date — the calendar's whole job is
// to make "this is slipping" visible before it becomes "this is late".
// Anything already settled is locked and simply reads as done.
const CASH_SOON_DAYS = 7;
function cashEventFlag(e, today) {
  const t = today || todayISO();
  if (e.actual) return { level: 'settled', icon: '\u2713', tone: 'green', label: e.direction === 'in' ? 'Received' : 'Paid' };
  if (!e.date) return { level: 'unscheduled', icon: '?', tone: 'neutral', label: 'No date set' };
  const days = daysBetween(t, e.date);      // negative once the date has passed
  if (days < 0) return { level: 'late', icon: '\u0021', tone: 'red', label: `${Math.abs(days)} day${Math.abs(days) === 1 ? '' : 's'} overdue` };
  if (days <= CASH_SOON_DAYS) return { level: 'soon', icon: '\u26a0', tone: 'yellow', label: days === 0 ? 'Due today' : `Due in ${days} day${days === 1 ? '' : 's'}` };
  return { level: 'planned', icon: '', tone: 'neutral', label: 'Planned' };
}

// ---------------------------------------------------------------------------
// Credit-card statement import
// ---------------------------------------------------------------------------
// Banks export CSV in whatever order they like, so the columns are detected by
// what they LOOK like rather than by position: a parsable date, a number, and
// whatever text is left. Every row lands in a review table before anything is
// created — an import that writes straight to the ledger is how bad data gets in.
// A CSV or spreadsheet paste WITH a header row, which is how supplier finish
// lists and stock exports arrive. Different from parseStatementCsv above,
// which infers columns from content because a bank export often has no usable
// header at all. Here the header is the information, so it is read and mapped.
function splitCsvLine(line) {
  return line.includes('\t')
    ? line.split('\t')
    : (line.match(/("([^"]|"")*"|[^,]*)(,|$)/g) || []).map(c => c.replace(/,$/, '').replace(/^"|"$/g, '').replace(/""/g, '"'));
}
function parseHeaderCsv(text) {
  const lines = String(text || '').split(/\r?\n/).filter(l => l.trim());
  if (lines.length < 2) return { headers: [], rows: [] };
  const headers = splitCsvLine(lines[0]).map(h => String(h || '').trim());
  const rows = lines.slice(1).map(l => {
    const cells = splitCsvLine(l);
    const o = {};
    headers.forEach((h, i) => { o[h] = (cells[i] === undefined ? '' : String(cells[i]).trim()); });
    return o;
  });
  return { headers, rows };
}
// Best header for a field, matched loosely so "Item Name", "item_name" and
// "NAME" all land on the same place without the person mapping them by hand.
function matchColumn(headers, candidates) {
  const squash = v => String(v || '').toLowerCase().replace(/[^a-z0-9]/g, '');
  const hs = headers.map(h => ({ h, k: squash(h) }));
  for (const c of candidates) {
    const ck = squash(c);
    const exact = hs.find(x => x.k === ck);
    if (exact) return exact.h;
  }
  for (const c of candidates) {
    const ck = squash(c);
    // Only match a substring for a candidate long enough to mean something. A
    // one- or two-letter alias like "w" or "h" partial-matches inside "HDW SET"
    // and "THK", which silently maps a width column onto the hardware set — the
    // kind of import error nobody notices until a hundred doors are wrong.
    if (ck.length < 3) continue;
    const partial = hs.find(x => x.k.includes(ck));
    if (partial) return partial.h;
  }
  return '';
}
// The fields each importer needs, and what a column might reasonably be called.
const FINISH_IMPORT_FIELDS = [
  { key: 'name', label: 'Finish name', required: true, aliases: ['name', 'decor name', 'product name', 'finish', 'colour', 'color name', 'description'] },
  { key: 'code', label: 'Supplier code', required: false, aliases: ['code', 'sku', 'item code', 'product code', 'ref', 'reference', 'article'] },
  { key: 'cat', label: 'Category', required: true, aliases: ['category', 'construction', 'type', 'range', 'product type', 'group'] },
  { key: 'collection', label: 'Collection', required: false, aliases: ['collection', 'series', 'line'] },
  { key: 'color', label: 'Colour', required: false, aliases: ['colour', 'color', 'shade'] },
  { key: 'style', label: 'Style', required: false, aliases: ['style', 'finish type', 'surface'] },
  { key: 'img', label: 'Image URL', required: false, aliases: ['image', 'image url', 'img', 'photo', 'picture', 'swatch'] },
];
const INVENTORY_IMPORT_FIELDS = [
  { key: 'name', label: 'Item name', required: true, aliases: ['name', 'item name', 'item', 'material', 'product', 'description'] },
  { key: 'itemId', label: 'Item no. / SKU', required: false, aliases: ['item id', 'item no', 'itemno', 'sku', 'code', 'part number', 'reference'] },
  { key: 'category', label: 'Category', required: true, aliases: ['category', 'type', 'group', 'class'] },
  { key: 'currentStock', label: 'Quantity', required: true, aliases: ['quantity', 'qty', 'stock', 'on hand', 'current stock', 'count'] },
  { key: 'unitOfMeasure', label: 'Unit', required: false, aliases: ['unit', 'uom', 'unit of measure', 'units'] },
  { key: 'unitCost', label: 'Unit cost', required: false, aliases: ['unit cost', 'cost', 'price', 'unit price', 'value'] },
  { key: 'storageLocation', label: 'Location', required: false, aliases: ['location', 'storage location', 'bin', 'rack', 'shelf', 'area'] },
  { key: 'manufacturerVendor', label: 'Vendor', required: false, aliases: ['vendor', 'supplier', 'manufacturer', 'brand', 'make'] },
  { key: 'finishColor', label: 'Finish / colour', required: false, aliases: ['finish', 'colour', 'color'] },
  { key: 'dimensions', label: 'Dimensions', required: false, aliases: ['dimensions', 'size', 'dims'] },
  { key: 'description', label: 'Description', required: false, aliases: ['description', 'notes', 'detail'] },
];
// Guess a mapping from the headers, so a well-formed export needs no mapping
// work at all and an odd one only needs the columns that were missed.
function guessColumnMap(headers, fields) {
  const map = {};
  const used = new Set();
  fields.forEach(f => {
    const h = matchColumn(headers.filter(x => !used.has(x)), f.aliases);
    if (h) { map[f.key] = h; used.add(h); }
  });
  return map;
}

function parseStatementCsv(text) {
  const rows = [];
  const lines = String(text || '').split(/\r?\n/).filter(l => l.trim());
  if (!lines.length) return rows;
  // Split on commas outside quotes, or on tabs when it's a paste from a sheet.
  const split = line => line.includes('\t')
    ? line.split('\t')
    : (line.match(/("([^"]|"")*"|[^,]*)(,|$)/g) || []).map(c => c.replace(/,$/, '').replace(/^"|"$/g, '').replace(/""/g, '"'));
  const parseDate = v => {
    const t = String(v || '').trim();
    let m = t.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
    if (m) return `${m[1]}-${String(m[2]).padStart(2, '0')}-${String(m[3]).padStart(2, '0')}`;
    m = t.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})$/);
    if (m) {
      const yr = m[3].length === 2 ? `20${m[3]}` : m[3];   // US m/d/y, the common bank export
      return `${yr}-${String(m[1]).padStart(2, '0')}-${String(m[2]).padStart(2, '0')}`;
    }
    return null;
  };
  const parseAmt = v => {
    const t = String(v || '').trim();
    if (!t) return null;
    // The WHOLE cell must look like money. Matching anything containing digits
    // swallowed real descriptions — "SHELL OIL 4471" and "HOME DEPOT #221" were
    // read as amounts and vanished from the description.
    if (!/^[-(]?\s*[$€£]?\s*\d[\d,]*(\.\d{1,2})?\s*\)?$/.test(t)) return null;
    const neg = /^\(.*\)$/.test(t) || /^-/.test(t);
    const n = Number(t.replace(/[()\-]/g, '').replace(/[^0-9.]/g, ''));
    if (!isFinite(n) || !n) return null;
    return neg ? -n : n;
  };
  lines.forEach((line, i) => {
    const cells = split(line).map(c => (c || '').trim());
    if (!cells.length) return;
    const date = cells.map(parseDate).find(Boolean);
    if (!date) return;                       // header rows and totals drop out here
    // The amount is the LAST numeric cell — bank exports often carry a running
    // balance first, and the charge is the one at the end.
    const amounts = cells.map(parseAmt).filter(v => v !== null);
    const amount = amounts.length ? amounts[amounts.length - 1] : null;
    if (amount === null) return;
    const desc = cells
      .filter(c => parseDate(c) === null && parseAmt(c) === null && c.length > 1)
      .join(' ').trim();
    rows.push({
      id: `imp-${i}`, include: true,
      date, description: desc || 'Card charge',
      // A negative figure on a card statement is a refund or payment, not a cost.
      amount: Math.abs(amount), isCredit: amount < 0,
      category: null, projectId: null,
    });
  });
  return rows;
}

// The actual email a share will send. Composed here rather than assembled ad
// hoc at the send site, so the preview in the Share modal and the message that
// eventually leaves the mail service are the same text — a preview that can
// drift from what sends is worse than no preview.
// The LEON palette, repeated here as literal hex because an email is rendered
// outside this page: CSS variables, external stylesheets and web fonts do not
// survive the trip into a mail client. Keep these in step with styles.css.
const EMAIL_BRAND = {
  black: '#161311', brown: '#6b4a34', brownLight: '#b08968',
  cream: '#f7f3ee', line: '#e5ded4', white: '#ffffff', muted: '#6f665e',
};
// Century Gothic is LEON's brand face. A mail client cannot be sent a font
// file, so the stack asks for it by name (many Macs and Windows machines have
// it) and falls back through the geometric sans faces closest to it before
// reaching Arial. The email looks like LEON where the font exists and stays
// clean where it does not.
const EMAIL_FONT = "'Century Gothic','Questrial','Futura','Avenir Next','Trebuchet MS',Arial,sans-serif";
function esc(v) {
  return String(v == null ? '' : v)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}
// Signatures are typed as plain text, so newlines have to become breaks or the
// whole block collapses onto one line in HTML.
function escLines(v) { return esc(v).replace(/\r?\n/g, '<br>'); }

// The HTML half of the share email. Table-based and inline-styled on purpose:
// that is what mail clients actually render — Outlook ignores <style> blocks,
// flexbox and grid, and Gmail strips a <head>. Nothing here needs to be
// clever, it needs to survive.
function buildShareEmailHtml(d) {
  const B = EMAIL_BRAND;
  const coName = d.coName;
  const row = (inner) => `<tr><td style="padding:0 32px;">${inner}</td></tr>`;
  const L = [];
  L.push(`<div style="background:${B.cream};padding:24px 12px;font-family:${EMAIL_FONT};">`);
  L.push(`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:600px;margin:0 auto;background:${B.white};border:1px solid ${B.line};border-radius:12px;overflow:hidden;">`);

  // Masthead — the wordmark is set as letter-spaced type rather than the SVG,
  // because a mail client will not render an inline SVG and many block remote
  // images by default. The lockup (LEON over a tracked-out second line) is the
  // brand's, even when the art itself cannot travel.
  L.push(emailMasthead('OPERATIONS HUB'));

  L.push(`<tr><td style="height:22px;"></td></tr>`);
  L.push(row(`<p style="margin:0 0 14px;font-size:15px;color:${B.black};">Hi ${esc((d.recipientName || '').split(' ')[0] || 'there')},</p>`));
  L.push(row(`<p style="margin:0 0 18px;font-size:14px;line-height:1.6;color:${B.muted};">`
    + `<span style="color:${B.black};font-weight:bold;">${esc(d.senderName || 'A colleague')}</span>`
    + `${d.senderTitle ? ` <span style="color:${B.muted};">(${esc(d.senderTitle)})</span>` : ''}`
    + ` at ${esc(coName)} has shared the following with you${d.projectName ? ` on <span style="color:${B.black};">${esc(d.projectName)}</span>` : ''}:</p>`));

  // The subject card — the one thing the email is about.
  L.push(row(`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${B.cream};border-left:3px solid ${B.brown};border-radius:6px;">`
    + `<tr><td style="padding:14px 16px;">`
    + `<div style="font-size:16px;font-weight:bold;color:${B.black};">${esc(d.subject)}</div>`
    + (d.summary ? `<div style="font-size:13px;color:${B.muted};margin-top:4px;">${esc(d.summary)}</div>` : '')
    + `</td></tr></table>`));

  if (d.message) {
    L.push(`<tr><td style="height:18px;"></td></tr>`);
    L.push(row(`<div style="font-size:14px;line-height:1.65;color:${B.black};">${escLines(d.message)}</div>`));
  }

  if (d.items && d.items.length) {
    L.push(`<tr><td style="height:20px;"></td></tr>`);
    L.push(row(`<div style="font-size:11px;letter-spacing:1.2px;text-transform:uppercase;color:${B.muted};font-weight:bold;margin-bottom:8px;">`
      + `Included (${d.items.length}${d.itemsTotal && d.itemsTotal !== d.items.length ? ` of ${d.itemsTotal}` : ''})</div>`));
    const cell = (i, indent) => `<tr><td style="padding:5px 0;border-bottom:1px solid ${B.line};font-size:13px;color:${B.black};${indent ? 'padding-left:16px;' : ''}">`
      + `${esc(i.label)}${i.bytes ? ` <span style="color:${B.muted};font-size:11px;">(${fmtBytes(i.bytes)})</span>` : ''}</td></tr>`;
    const list = (arr) => {
      const out = [];
      let group = null;
      arr.forEach(i => {
        if (i.group && i.group !== group) {
          group = i.group;
          out.push(`<tr><td style="padding:10px 0 3px;font-size:11px;font-weight:bold;color:${B.brown};text-transform:uppercase;letter-spacing:0.8px;">${esc(i.group)}</td></tr>`);
        }
        out.push(cell(i, !!i.group));
      });
      return out.join('');
    };
    const files = d.items.filter(i => i.bytes);
    const attach = files.filter(i => i.delivery !== 'link');
    const linked = files.filter(i => i.delivery === 'link');
    const plain = d.items.filter(i => !i.bytes);
    L.push(row(`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">${list([...plain, ...attach])}</table>`));
    if (linked.length) {
      L.push(row(`<div style="margin-top:16px;font-size:11px;letter-spacing:1.2px;text-transform:uppercase;color:${B.muted};font-weight:bold;margin-bottom:6px;">Too large to attach &mdash; sent as a download link</div>`
        + `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">${list(linked)}</table>`));
    }
  }

  L.push(`<tr><td style="height:22px;"></td></tr>`);
  L.push(row(`<p style="margin:0;font-size:13px;color:${B.muted};">`
    + (d.external ? 'Reply to this email if you need anything else.' : 'You can also find this in the LEON Operations Hub.')
    + `</p>`));
  L.push(`<tr><td style="height:24px;"></td></tr>`);

  // Sign-off. A person's own signature wins verbatim; otherwise the standard
  // block is built from the company profile so every email closes the same way.
  L.push(`<tr><td style="padding:20px 32px;border-top:1px solid ${B.line};background:${B.cream};">`);
  if (d.senderSignature && d.senderSignature.trim()) {
    L.push(`<div style="font-size:12px;line-height:1.6;color:${B.black};">${escLines(d.senderSignature.trim())}</div>`);
  } else {
    L.push(`<div style="font-size:13px;font-weight:bold;color:${B.black};">${esc(d.senderName || '')}</div>`);
    if (d.senderTitle) L.push(`<div style="font-size:12px;color:${B.muted};">${esc(d.senderTitle)}</div>`);
    L.push(`<div style="font-size:12px;font-weight:bold;letter-spacing:1.5px;color:${B.brown};margin-top:8px;">${esc(coName.toUpperCase())}</div>`);
    const addr = [d.co.addressLine1, d.co.addressLine2].filter(Boolean).join(', ');
    if (addr) L.push(`<div style="font-size:12px;color:${B.muted};">${esc(addr)}</div>`);
    const contact = [d.co.phone, d.co.email, d.co.website].filter(Boolean).map(esc).join(' &nbsp;&middot;&nbsp; ');
    if (contact) L.push(`<div style="font-size:12px;color:${B.muted};margin-top:2px;">${contact}</div>`);
  }
  L.push(`</td></tr>`);
  L.push(`</table>`);
  L.push(`<div style="max-width:600px;margin:12px auto 0;text-align:center;font-size:10px;color:${B.muted};font-family:${EMAIL_FONT};">Sent from the LEON Operations Hub</div>`);
  L.push(`</div>`);
  return L.join('');
}

function buildShareEmail({ recipientName, senderName, senderTitle, senderSignature, subject, summary, message, items, itemsTotal, projectName, company, external }) {
  const co = company || {};
  const coName = co.tradeName || co.name || 'LEON Integra';
  const L = [];
  L.push(`Hi ${(recipientName || '').split(' ')[0] || 'there'},`);
  L.push('');
  L.push(`${senderName || 'A colleague'}${senderTitle ? ` (${senderTitle})` : ''} at ${coName} has shared the following with you${projectName ? ` on ${projectName}` : ''}:`);
  L.push('');
  L.push(subject);
  if (summary) L.push(summary);
  if (message) { L.push(''); L.push(message); }

  // Files split by how they can actually be delivered. Anything over the
  // attachment ceiling has to go as a link — saying "attached" about a 40MB
  // drawing set would simply be untrue.
  const files = (items || []).filter(i => i.bytes);
  const attach = files.filter(i => i.delivery !== 'link');
  const linked = files.filter(i => i.delivery === 'link');
  const plain = (items || []).filter(i => !i.bytes);
  if (items && items.length) {
    L.push('');
    L.push(`Included (${items.length}${itemsTotal && itemsTotal !== items.length ? ` of ${itemsTotal}` : ''}):`);
    let group = null;
    const line = i => {
      if (i.group && i.group !== group) { group = i.group; L.push(`  ${i.group}`); }
      const size = i.bytes ? ` (${fmtBytes(i.bytes)})` : '';
      L.push(`${i.group ? '    - ' : '  - '}${i.label}${size}`);
    };
    [...plain, ...attach].forEach(line);
    if (linked.length) {
      group = null;
      L.push('');
      L.push('Too large to attach — sent as a download link:');
      linked.forEach(line);
    }
  }

  L.push('');
  L.push(external
    ? 'Reply to this email if you need anything else.'
    : 'You can also find this in the LEON Operations Hub.');
  L.push('');
  // Branded sign-off. A person's own signature wins; otherwise the standard
  // block is built from the company profile so every email looks the same.
  L.push('—');
  if (senderSignature && senderSignature.trim()) {
    L.push(senderSignature.trim());
  } else {
    L.push(`${senderName || ''}${senderTitle ? `, ${senderTitle}` : ''}`.trim());
    L.push(coName.toUpperCase());
    const addr = [co.addressLine1, co.addressLine2].filter(Boolean).join(', ');
    if (addr) L.push(addr);
    const contact = [co.phone, co.email, co.website].filter(Boolean).join('  ·  ');
    if (contact) L.push(contact);
  }
  return {
    subject: `${projectName ? `[${projectName}] ` : ''}${subject}`,
    body: L.join('\n'),
    // Both halves are kept: a real mail service sends multipart, and the plain
    // text is the fallback for a client that will not render HTML.
    html: buildShareEmailHtml({
      recipientName, senderName, senderTitle, senderSignature, subject, summary, message,
      items, itemsTotal, projectName, external, co, coName,
    }),
    attachCount: attach.length,
    linkCount: linked.length,
    linkBytes: linked.reduce((n, i) => n + (i.bytes || 0), 0),
  };
}

// The brief that goes to Claude when an AI request is raised. Written as a
// standalone prompt: whoever reads it has none of this app's context, so the
// project, the scope, the drawing set and the ask all have to be in the text.
//
// A browser cannot attach the drawing files to it — that has to be done by
// hand in the Claude window — so the prompt says which set to attach rather
// than pretending they came along.
function buildAiRequestPrompt({ deliverable, project, scope, drawingSet, instructions, company }) {
  const L = [];
  const co = (company && (company.tradeName || company.name)) || 'LEON Integra';
  L.push(`I need a ${String(deliverable || 'take-off').toLowerCase()} produced for a construction / interior finishes project.`);
  L.push('');
  L.push('PROJECT');
  L.push(`  Company: ${co}`);
  if (project) {
    L.push(`  Project: ${project.name}${project.projectNumber ? ` (${project.projectNumber})` : ''}`);
    if (project.address) L.push(`  Address: ${project.address}`);
    if (project.projectType) L.push(`  Type: ${project.projectType}`);
  }
  if (scope) {
    L.push('');
    L.push('SCOPE');
    L.push(`  ${scope.name}${scope.familyName ? ` — ${scope.familyName}` : ''}`);
    if (scope.scopeType) L.push(`  Sold as: ${scope.scopeType}`);
    if (scope.quantity) L.push(`  Quantity on record: ${scope.quantity} ${scope.unit || ''}`.trim());
  }
  if (drawingSet) {
    L.push('');
    L.push('DRAWINGS');
    L.push(`  ${drawingSet.name || 'Drawing set'}${drawingSet.revision ? ` — Rev ${drawingSet.revision}` : ''}`);
    if (drawingSet.dateReceived) L.push(`  Received ${drawingSet.dateReceived}${drawingSet.source ? ` from ${drawingSet.source}` : ''}`);
    L.push('  (Attach this set in the Claude window — it cannot be sent from the Hub.)');
  }
  L.push('');
  L.push('WHAT I NEED');
  L.push(`  ${instructions && instructions.trim() ? instructions.trim() : `A ${deliverable} for the scope above.`}`);

  // A take-off has a house format, so the brief carries it rather than leaving
  // the shape to be guessed and then reworked by hand on the way back in.
  if (String(deliverable || '').toLowerCase().includes('take-off')) {
    const t = takeoffScopeFor(scope && scope.familyName, scope && scope.name);
    L.push('');
    L.push('FORMAT — return the take-off in the LEON Take-Off Template layout');
    L.push('  One table per scope. Columns, in this order:');
    L.push(`    ${['Area', 'Category', 'Location / Room', 'Item Tag', 'Description'].join(' | ')}`);
    if (t) {
      L.push(`    ${t.specs.join(' | ')}      <- specification columns for ${t.key}`);
    } else {
      L.push('    six specification columns named for the trade (material, finish, size, etc.)');
    }
    L.push(`    ${['Unit Type', 'Qty per Unit', 'Unit Qty', 'Subtotal', 'Waste %', 'Waste Qty', 'TOTAL QTY', 'UoM', 'Drawing Ref', 'Measured By', 'Date', 'Status', 'Notes'].join(' | ')}`);
    L.push('');
    L.push('  Rules:');
    L.push('    Unit Qty  = how many of that unit type there are (e.g. 42 of Unit A)');
    L.push('    Subtotal  = Qty per Unit x Unit Qty');
    L.push('    Waste Qty = Subtotal x Waste %');
    L.push('    TOTAL QTY = Subtotal + Waste Qty');
    if (t) {
      L.push(`    Waste %   = ${Math.round(t.std * 100)}% standard for this scope, ${Math.round(t.hi * 100)}% where the layout is complex. Say on the line which you used and why.`);
      L.push(`    UoM       = ${t.uom} throughout.`);
    }
    L.push('    Status    = Measured, To Verify, or Not In Scope. Anything you had to assume is To Verify, with the assumption in Notes.');
    L.push('    Every line carries the Drawing Ref (sheet number) it was measured from.');
    L.push('');
    L.push('  Area and Category are two separate columns and both are required — they are');
    L.push('  what the quote is priced on, since residences and amenities do not carry the');
    L.push('  same rate. Never merge them into one total.');
    L.push(`    Area     = one of: ${TAKEOFF_AREAS.join(', ')}`);
    if (t) L.push(`    Category = one of: ${t.cats.join(', ')}`);
    L.push('');
    L.push('  Give it to me as a table I can paste straight into the scope tab, and show your');
    L.push('  arithmetic per line so it can be checked without re-measuring.');
  }

  L.push('');
  L.push('Please ask me for anything you need before starting, and set out the result so it can be checked line by line.');
  return L.join('\n');
}

// One scope's shop-drawing material, grouped by submittal thread so a section
// is a thread and its files are its revisions. Same shape the Shop Drawing Hub
// itself uses, which is why the picker reads like the screen behind it.
function scopeDrawingPackageItems(scope) {
  const out = [];
  (scope.submittals || []).forEach(t => {
    [...(t.revisions || [])]
      .sort((a, b) => b.revisionNumber - a.revisionNumber)
      .forEach(r => {
        const bytes = approxFileBytes(r.fileUrl);
        out.push({
          group: t.name || 'Submittal', id: r.id,
          label: `Rev. ${r.revisionNumber}${r.file ? ` — ${r.file}` : ''}`,
          sub: [`${r.status || ''}`, r.date ? fmtDate(r.date) : '', bytes ? fmtBytes(bytes) : ''].filter(Boolean).join(' · '),
          bytes, delivery: bytes ? emailDelivery(bytes) : null,
        });
      });
  });
  (scope.documents || []).forEach(d => {
    const bytes = approxFileBytes(d.fileUrl);
    out.push({
      group: 'Documents', id: d.id, label: d.name || d.file || 'Document',
      sub: [d.category || d.type || '', d.date ? fmtDate(d.date) : '', bytes ? fmtBytes(bytes) : ''].filter(Boolean).join(' · '),
      bytes, delivery: bytes ? emailDelivery(bytes) : null,
    });
  });
  return out;
}

// One scope's selections, grouped by application area so a section is an area
// and its items are the categories chosen inside it. `scope.selections` is a
// map of categoryId -> optionName for the MAIN area; `selectionAreas` holds the
// additional named areas, each with its own map. Category names come from the
// scope library, which is where they live.
function scopeSelectionPackageItems(scope, scopeLibrary) {
  const family = (scopeLibrary || []).find(f => f.name === scope.familyName);
  const cats = family ? family.categories : [];
  const catName = id => (cats.find(c => c.id === id) || {}).name || 'Selection';
  const out = [];
  const push = (areaName, areaId, map) => {
    Object.entries(map || {}).forEach(([catId, val]) => {
      if (!val) return;
      out.push({ group: areaName, id: `${areaId}-${catId}`, label: catName(catId), sub: String(val) });
    });
  };
  push(scope.mainAreaName || 'Main area', scope.id, scope.selections);
  (scope.selectionAreas || []).forEach(a => push(a.name || 'Area', a.id, a.selections));
  return out;
}

// Everything filed against one scope, grouped by the section it lives in, ready
// for a package share. Sections, then the individual files inside them — which
// is how someone actually thinks about "send the client the cabinetry package,
// but only the approved drawings".
function scopePackageItems(project, scope) {
  const out = [];
  const add = (group, id, label, sub) => { if (label) out.push({ group, id, label, sub: sub || '' }); };

  (scope.submittals || []).forEach(t => {
    [...(t.revisions || [])]
      .sort((a, b) => b.revisionNumber - a.revisionNumber)
      .forEach(r => add('Shop Drawings & Submittals', r.id,
        `${t.name} — Rev. ${r.revisionNumber}${r.file ? ` (${r.file})` : ''}`,
        `${r.status || ''}${r.date ? ` · ${fmtDate(r.date)}` : ''}`.replace(/^ · /, '')));
  });

  (scope.documents || []).forEach(d => add('Documents', d.id, d.name || d.file || 'Document',
    `${d.category || ''}${d.date ? ` · ${fmtDate(d.date)}` : ''}`.replace(/^ · /, '')));

  // Selections are entries rather than files, but they are the thing a client
  // is most often sent, so they belong in the same package.
  const areaNames = [scope.mainAreaName || 'Main area', ...(scope.selectionAreas || []).map(a => a.name || 'Area')];
  areaNames.forEach((nm, i) => add('Selections', `${scope.id}-area-${i}`, nm,
    scope.selectionsLocked ? 'Locked' : 'Not locked'));

  (project.productionRecords || []).filter(r => r.scopeId === scope.id).forEach(r => {
    (r.drawings || []).forEach(d => add('Production', d.id, d.file || d.name || 'Production drawing', `${r.vendorName || ''} · drawing`));
    (r.qcReports || []).forEach(d => add('Production', d.id, d.file || d.name || 'QC report', `${r.vendorName || ''} · QC`));
    (r.photos || []).forEach((d, i) => add('Production', d.id || `${r.id}-photo-${i}`, d.file || `Photo ${i + 1}`, `${r.vendorName || ''} · photo`));
  });

  (project.takeOffs || []).filter(t => !t.scopeId || t.scopeId === scope.id).forEach(t =>
    add('Take-offs', t.id, t.name || t.title || 'Take-off',
      `${(t.items || t.lines || []).length} line${(t.items || t.lines || []).length === 1 ? '' : 's'}`));

  (project.renderSets || []).filter(r => !r.scopeId || r.scopeId === scope.id).forEach(r =>
    add('Renders', r.id, r.name || 'Render set',
      `${(r.images || r.files || []).length} image${(r.images || r.files || []).length === 1 ? '' : 's'}`));

  return out;
}

// ---------------------------------------------------------------------------
// Job progress, read from an accountant's seat
// ---------------------------------------------------------------------------
// Accounting's question is never "what is the production status" on its own —
// it is "how far along is the work behind the money I am billing and paying".
// So every row pairs physical progress with the money against that same scope,
// and the gap between the two percentages is the point of the whole view.
const PRODUCTION_PCT = { 'Not Started': 0, 'In Production': 50, 'Quality Check': 80, 'Complete': 100 };
const INSTALL_PCT = {
  'Not Ready': 0, 'Ready': 10, 'Material On Site': 25, 'Installation Started': 50,
  'Installation Complete': 85, 'QC Required': 90, 'Punch': 95, 'Approved/Closed': 100,
};
function avg(nums) { return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0; }

// Money booked against one scope: what it sells for, what it has cost, and
// what has actually been paid out on it.
function scopeMoney(project, scope) {
  const prof = scope.profitability || {};
  const salesValue = Number(prof.salesValue) || 0;
  const sum = obj => Object.values(obj || {}).reduce((n, v) => n + (Number(v) || 0), 0);
  const estimatedCost = sum(prof.costs);
  const actualCost = sum(prof.actual);
  // AP actually paid, attributed to this scope either directly or by line.
  let committed = 0, paid = 0;
  (project.apInvoices || []).filter(apInvoiceLive).forEach(inv => {
    const lines = (inv.lines || []).filter(l => l.scopeId === scope.id);
    const amt = lines.length
      ? lines.reduce((n, l) => n + (Number(l.amount) || 0), 0)
      : (inv.scopeId === scope.id ? Number(inv.amount) || 0 : 0);
    if (!amt) return;
    committed += amt;
    const invTotal = Number(inv.amount) || 0;
    const invPaid = (inv.payments || []).reduce((n, x) => n + (Number(x.amount) || 0), 0);
    // A multi-scope invoice pays down proportionally — there is no per-line
    // payment record, and splitting it any other way would overstate one scope.
    paid += invTotal ? amt * (invPaid / invTotal) : 0;
  });
  return { salesValue, estimatedCost, actualCost, committed, paid };
}

// One row per scope. `mode` picks which side of the work the row describes.
function buildJobProgressRows(projects, accounts, mode) {
  const rows = [];
  (projects || []).forEach(p => {
    const account = (accounts || []).find(a => a.id === p.accountId);
    (p.scopes || []).forEach(sc => {
      const money = scopeMoney(p, sc);
      const base = {
        id: p.id + ':' + sc.id,
        projectId: p.id, projectName: p.name, projectNumber: p.projectNumber,
        projectStatus: p.status,
        clientName: account ? account.name : (p.clientName || ''),
        department: scopeDepartment(sc), scopeId: sc.id, scopeName: sc.name,
        family: sc.familyName || '', ...money,
      };
      if (mode === 'installation') {
        const recs = (p.installationRecords || []).filter(r => r.scopeId === sc.id);
        const punch = (p.punchItems || []).filter(x => x.scopeId === sc.id);
        const openPunch = punch.filter(x => x.status !== 'Closed').length;
        const starts = recs.map(r => r.actualStart).filter(Boolean).sort();
        const ends = recs.map(r => r.actualCompletion).filter(Boolean).sort();
        const done = recs.filter(r => r.status === 'Approved/Closed').length;
        rows.push({
          ...base,
          count: recs.length, done,
          // A record's own pctComplete beats the status ladder when it is set —
          // the field crew's number is more precise than the stage it sits in.
          pct: recs.length ? avg(recs.map(r => (Number(r.pctComplete) > 0 ? Number(r.pctComplete) : (INSTALL_PCT[r.status] || 0)))) : null,
          status: recs.length ? (done === recs.length ? 'Approved/Closed' : (recs.find(r => r.status !== 'Approved/Closed') || {}).status || '—') : 'Not scheduled',
          started: starts[0] || null, completed: done === recs.length && recs.length ? ends[ends.length - 1] : null,
          openPunch,
          crew: [...new Set(recs.map(r => r.assignedCrew).filter(Boolean))].join(', '),
        });
      } else {
        const recs = (p.productionRecords || []).filter(r => r.scopeId === sc.id);
        const starts = recs.map(r => r.startedDate).filter(Boolean).sort();
        const ends = recs.map(r => r.completedDate).filter(Boolean).sort();
        const done = recs.filter(r => r.status === 'Complete').length;
        rows.push({
          ...base,
          count: recs.length, done,
          pct: recs.length ? avg(recs.map(r => PRODUCTION_PCT[r.status] || 0)) : null,
          status: recs.length ? (done === recs.length ? 'Complete' : (recs.find(r => r.status !== 'Complete') || {}).status || '—') : 'No production record',
          started: starts[0] || null, completed: done === recs.length && recs.length ? ends[ends.length - 1] : null,
          openPunch: null,
          crew: [...new Set(recs.map(r => r.vendorName).filter(Boolean))].join(', '),
        });
      }
    });
  });
  return rows;
}

// ---------------------------------------------------------------------------
// Deadline alerts and calendar export
// ---------------------------------------------------------------------------
// The other half of the inbox: things that are true right now rather than
// things that happened. Recomputed on every render from the same items the
// person sees in My To-Do, so there is no stored state to go stale and no
// background job to run. `items` comes from collectMyItems (app.jsx).
const ALERT_SOON_DAYS = 3;
// ---------------------------------------------------------------------------
// Lead-time revisions — what actually changed between two saves
// ---------------------------------------------------------------------------
// The stage templates are edited live, like everything else in this app, so a
// half-finished edit is never lost. What was missing is the RECORD: who changed
// the company's default schedule, when, and what moved. This diffs the library
// against the last saved snapshot and returns plain sentences — a stored diff
// would go stale the moment a stage is renamed, so it is computed at save time
// from the two snapshots and then kept verbatim.
// Totals for a bill of quantities, at every level the bill is read at.
//
// The arithmetic that matters: a line's `qty` is PER APARTMENT, so the money it
// contributes is `qty × rate × the unit type's count`. Both figures are
// returned — the per-apartment rate is what gets negotiated, the extended
// figure is what gets contracted — because showing only one of them is how a
// bill gets misread.
//
// A line with no rate contributes NOTHING and is counted as unpriced, exactly
// as the countertop estimate does. A bill that quietly totals an unset rate as
// zero is how a job is sold at a loss.
function boqTotals(boq) {
  const lines = (boq && boq.lines) || [];
  const types = (boq && boq.unitTypes) || [];
  const rate = Number(boq && boq.fxRate) || null;

  const byType = {};
  types.forEach(t => { byType[t.id] = { type: t, scopes: {}, perUnit: 0, extended: 0, unpriced: 0, lines: 0 }; });
  // A line whose unit type was deleted still has to appear somewhere, or money
  // silently leaves the bill.
  const orphan = { type: null, scopes: {}, perUnit: 0, extended: 0, unpriced: 0, lines: 0 };

  let perUnitAll = 0, extendedAll = 0, unpricedAll = 0;
  lines.forEach(l => {
    const bucket = byType[l.unitTypeId] || orphan;
    const count = bucket.type ? Math.max(0, Number(bucket.type.count) || 0) : 1;
    const qty = Number(l.qty) || 0;
    const r = (l.rate === null || l.rate === undefined || l.rate === '') ? null : Number(l.rate);
    const per = r === null ? 0 : qty * r;
    const ext = per * count;
    bucket.lines += 1;
    if (r === null) { bucket.unpriced += 1; unpricedAll += 1; }
    bucket.perUnit += per; bucket.extended += ext;
    perUnitAll += per; extendedAll += ext;

    const fam = l.familyName || 'Unassigned';
    const sc = bucket.scopes[fam] || (bucket.scopes[fam] = { familyName: fam, items: {}, perUnit: 0, extended: 0, unpriced: 0 });
    sc.perUnit += per; sc.extended += ext; if (r === null) sc.unpriced += 1;
    const wi = l.workItem || '—';
    const it = sc.items[wi] || (sc.items[wi] = { workItem: wi, lines: [], perUnit: 0, extended: 0 });
    it.lines.push(l); it.perUnit += per; it.extended += ext;
  });

  const groups = types.map(t => byType[t.id]);
  if (orphan.lines) groups.push(orphan);
  return {
    groups, perUnitAll, extendedAll, unpricedAll,
    complete: unpricedAll === 0 && lines.length > 0,
    // The second currency is a conversion of the same money, never a separate
    // total — so it can never disagree with the contract figure.
    secondary: rate ? extendedAll * rate : null,
    fxRate: rate, fxDate: (boq && boq.fxDate) || null,
  };
}

function leadTimeSnapshot(interior, windows, complexity) {
  return {
    interior: (interior || []).map(e => ({
      id: e.id, family: e.familyName || e.family || '', active: e.active !== false,
      stageDays: Object.assign({}, e.stageDays),
      stageTemplates: JSON.parse(JSON.stringify(e.stageTemplates || {})),
    })),
    windows: (windows || []).map(w => Object.assign({}, w)),
    complexity: (complexity || []).map(c => ({ id: c.id, name: c.name, multiplier: c.multiplier, active: c.active !== false })),
  };
}
function leadTimeDiff(before, after) {
  const out = [];
  // With no previous revision there is nothing to diff AGAINST — every entry
  // would read as "added", which is noise, not history. The first save is a
  // baseline and says so.
  if (!before) return ['Baseline — the default schedule as it stands today.'];
  const b = before;
  const byId = list => Object.fromEntries((list || []).map(x => [x.id, x]));
  const bi = byId(b.interior), ai = byId(after.interior);

  Object.values(ai).forEach(e => {
    const prev = bi[e.id];
    const who = e.family || 'a scope family';
    if (!prev) { out.push(`Added a template for ${who}.`); return; }
    if ((prev.active !== false) !== (e.active !== false)) {
      out.push(`${who} was ${e.active === false ? 'retired' : 'brought back'}.`);
    }
    Object.keys(e.stageDays || {}).forEach(k => {
      const was = prev.stageDays ? prev.stageDays[k] : undefined;
      if (was !== undefined && was !== e.stageDays[k]) {
        out.push(`${who} — ${k}: ${was} → ${e.stageDays[k]} days.`);
      } else if (was === undefined) {
        out.push(`${who} — ${k} set to ${e.stageDays[k]} days.`);
      }
    });
    const pv = prev.stageTemplates || {}, nv = e.stageTemplates || {};
    Object.keys(nv).forEach(variant => {
      const a = (nv[variant] || []).join('|'), p = (pv[variant] || []).join('|');
      if (!(variant in pv)) out.push(`${who} — ${variant}: stage list customised (${(nv[variant] || []).length} stages).`);
      else if (a !== p) out.push(`${who} — ${variant}: stage list changed (${(pv[variant] || []).length} → ${(nv[variant] || []).length} stages).`);
    });
    Object.keys(pv).forEach(variant => {
      if (!(variant in nv)) out.push(`${who} — ${variant}: reset to the built-in stage list.`);
    });
  });
  Object.values(bi).forEach(e => { if (!ai[e.id]) out.push(`Removed the template for ${e.family}.`); });

  const bc = byId(b.complexity), ac = byId(after.complexity);
  Object.values(ac).forEach(c => {
    const prev = bc[c.id];
    if (!prev) { out.push(`Added complexity level "${c.name}" at ${c.multiplier}×.`); return; }
    if (prev.name !== c.name) out.push(`Complexity "${prev.name}" renamed to "${c.name}".`);
    if (Number(prev.multiplier) !== Number(c.multiplier)) out.push(`Complexity "${c.name}": ${prev.multiplier}× → ${c.multiplier}×.`);
    if ((prev.active !== false) !== (c.active !== false)) out.push(`Complexity "${c.name}" was ${c.active === false ? 'retired' : 'brought back'}.`);
  });

  const bw = byId(b.windows), aw = byId(after.windows);
  Object.values(aw).forEach(w => {
    const prev = bw[w.id];
    if (!prev) { out.push(`Added window system "${w.systemName || w.name || w.id}".`); return; }
    Object.keys(w).forEach(k => {
      if (k === 'id' || typeof w[k] === 'object') return;
      if (prev[k] !== undefined && prev[k] !== w[k]) out.push(`${w.systemName || w.name || 'Window system'} — ${k}: ${prev[k]} → ${w[k]}.`);
    });
  });
  return out;
}

function buildDueAlerts(items, today) {
  const t = today || todayISO();
  const out = [];
  (items || []).forEach(it => {
    if (it.completed || !it.date) return;
    const days = daysBetween(t, it.date);
    if (days > ALERT_SOON_DAYS) return;
    out.push({
      id: 'alert-' + it.id,
      level: days < 0 ? 'late' : 'soon',
      days,
      title: it.title,
      kind: it.kind,
      projectName: it.projectName || '',
      date: it.date,
      label: days < 0
        ? `${Math.abs(days)} day${Math.abs(days) === 1 ? '' : 's'} overdue`
        : days === 0 ? 'Due today' : `Due in ${days} day${days === 1 ? '' : 's'}`,
      raw: it.raw,
    });
  });
  // Most urgent first — overdue before upcoming, oldest overdue at the top.
  out.sort((a, b) => a.days - b.days);
  return out;
}

// ---- iCalendar export ------------------------------------------------------
// One-way: a snapshot of what is on your plate that Outlook (or Google, or
// Apple Calendar) can import. It is a file, not a live subscription — a live
// feed needs a URL a calendar server can poll, which needs a backend.
function icsEscape(s) {
  return String(s == null ? '' : s)
    .replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,')
    .replace(/\r?\n/g, '\\n');
}
// RFC 5545 caps a content line at 75 octets; longer lines continue with a
// leading space. Outlook is strict about this where other clients are not.
function icsFold(line) {
  if (line.length <= 73) return line;
  const parts = [line.slice(0, 73)];
  let rest = line.slice(73);
  while (rest.length > 72) { parts.push(' ' + rest.slice(0, 72)); rest = rest.slice(72); }
  if (rest) parts.push(' ' + rest);
  return parts.join('\r\n');
}
function icsDate(iso) { return (iso || '').replace(/-/g, ''); }
function icsStamp() { return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); }

// Each item becomes an all-day event on its due date — these are deadlines,
// not appointments with a start and end time, and an all-day block is what
// reads correctly in a calendar.
function buildIcsForItems(items, ownerName) {
  const lines = [
    'BEGIN:VCALENDAR', 'VERSION:2.0',
    'PRODID:-//LEON Integra//LEON Operations Hub//EN',
    'CALSCALE:GREGORIAN', 'METHOD:PUBLISH',
    'X-WR-CALNAME:' + icsEscape('LEON Ops — ' + (ownerName || 'My Work')),
    'X-WR-TIMEZONE:UTC',
  ];
  (items || []).forEach(it => {
    if (!it.date || it.completed) return;
    lines.push('BEGIN:VEVENT');
    lines.push('UID:' + it.id + '@leon-operations-hub');
    lines.push('DTSTAMP:' + icsStamp());
    lines.push('DTSTART;VALUE=DATE:' + icsDate(it.date));
    lines.push('DTEND;VALUE=DATE:' + icsDate(addDays(it.date, 1)));   // DTEND is exclusive
    lines.push('SUMMARY:' + icsEscape(`[${it.kind}] ${it.title}`));
    lines.push('DESCRIPTION:' + icsEscape([it.projectName, it.kind].filter(Boolean).join(' \u00b7 ')));
    if (it.projectName) lines.push('LOCATION:' + icsEscape(it.projectName));
    lines.push('CATEGORIES:' + icsEscape(it.kind));
    lines.push('TRANSP:TRANSPARENT');
    lines.push('END:VEVENT');
  });
  lines.push('END:VCALENDAR');
  return lines.map(icsFold).join('\r\n') + '\r\n';
}
function downloadIcs(filename, text) {
  const blob = new Blob([text], { type: 'text/calendar;charset=utf-8' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename.endsWith('.ics') ? filename : filename + '.ics';
  document.body.appendChild(a); a.click(); document.body.removeChild(a);
  URL.revokeObjectURL(url);
}

// Buckets events into consecutive weeks starting from the Sunday of `from`.
// `settings` (makeCashSettings) turns the running total into a real forecast of
// the bank position: it starts from a known opening balance, and any week can
// carry a manual override that resets the balance from that point forward
// without disturbing the weeks before it.
function bucketCashByWeek(events, from, weeks, settings, unit) {
  const start = startOfWeekISO(from);
  const buckets = [];
  // 'day' splits a single week into its seven days; otherwise each bucket is a
  // week. Same balance-carrying logic either way.
  if (unit === 'day') {
    for (let i = 0; i < 7; i++) {
      const d = addDays(start, i);
      buckets.push({ start: d, end: d, in: 0, out: 0, events: [], isDay: true });
    }
  } else {
    for (let i = 0; i < weeks; i++) {
      const wStart = addDays(start, i * 7);
      const wEnd = addDays(wStart, 6);
      buckets.push({ start: wStart, end: wEnd, in: 0, out: 0, events: [] });
    }
  }
  events.forEach(e => {
    const b = buckets.find(x => e.date >= x.start && e.date <= x.end);
    if (!b) return;
    b.events.push(e);
    if (e.direction === 'in') b.in += e.amount; else b.out += e.amount;
  });

  const cfg = settings || {};
  const overrides = cfg.weekOverrides || {};
  // Opening figure for the FIRST week shown. If the view starts after the
  // anchor date we can't know what happened in between, so the anchor is used
  // as-is and the user is expected to set an override — better than silently
  // presenting a number built on a gap.
  let opening = Number(cfg.openingBalance) || 0;
  buckets.forEach((b, i) => {
    if (overrides[b.start] != null) opening = Number(overrides[b.start]) || 0;
    b.opening = opening;
    b.overridden = overrides[b.start] != null;
    b.net = b.in - b.out;
    b.running = opening + b.net;      // closing balance for this week
    opening = b.running;              // next week opens where this one closed
  });
  return buckets;
}

function findScope(project, scopeId) {
  return project.scopes.find(s => s.id === scopeId);
}

// The Production Timeline and the Production tab's per-vendor records must
// read the SAME production dates as Scopes & Schedule — not carry a second,
// independently-editable copy — so both derive from this one lookup rather
// than storing their own start/end. Actual dates win over planned ones once
// the stage has genuinely started/finished; before that, planned dates are
// the best estimate everything downstream can key off of.
function productionScheduleStage(project, scopeId) {
  const scope = findScope(project, scopeId);
  return scope ? scope.stages.find(st => st.key === 'production') : null;
}
function productionDateRange(project, scopeId) {
  const stage = productionScheduleStage(project, scopeId);
  if (!stage) return { start: null, end: null, stage: null };
  return { start: stage.actualStart || stage.plannedStart, end: stage.actualComplete || stage.plannedDue, stage };
}

// ---------------------------------------------------------------------------
// Delay cascade (§2 — "the single most important piece of logic in the system")
// Reporting a delay on any stage shifts planned start/due of every downstream
// stage in that scope by the same number of days, recalculates the scope's
// projected completion, and updates the project's health indicator.
// ---------------------------------------------------------------------------
// Snapshots the scope's ENTIRE stages array before a status-changing action
// (Start / Complete / Report Delay), so that action can be undone as one
// step later. Scope-wide rather than per-stage because Report Delay cascades
// planned dates across every downstream stage in a single action — undoing
// just the one stage it was invoked on would leave the others' shifted dates
// behind. Capped so an old project's history can't grow unbounded.
// stageId names which row the change was actually made on (the stage
// Start/Complete was clicked for, or the stage Report Delay was reported
// against — not whichever downstream stages a delay cascade also touched)
// so the Undo control can be shown on that one specific row rather than as
// one shared scope-wide button (§ per-stage undo request). The restore
// itself still swaps in the whole prior stages array, since a delay cascade
// touches more than one stage's dates in a single action.
function pushStageHistory(scope, stageId) {
  scope.stageHistory = scope.stageHistory || [];
  // projectedCompletion is snapshotted alongside stages — Report Delay and
  // Add Revision both set it explicitly, so restoring stages without also
  // restoring this would leave the scope's projected-completion date stale
  // (still reflecting the undone change) even though every stage row is
  // back to normal.
  scope.stageHistory.push({ stageId, stages: cloneDeep(scope.stages), projectedCompletion: scope.projectedCompletion || null });
  if (scope.stageHistory.length > 20) scope.stageHistory.shift();
}

function applyDelayCascade(project, scopeId, stageId, delayDays, reasonCategory, note, actingRole, actingUser) {
  const next = cloneDeep(project);
  const scope = findScope(next, scopeId);
  if (!scope) return project;
  const idx = scope.stages.findIndex(s => s.id === stageId);
  if (idx === -1) return project;
  pushStageHistory(scope, stageId);

  const stage = scope.stages[idx];
  stage.delayDays = (stage.delayDays || 0) + delayDays;
  stage.delayReason = reasonCategory;
  stage.delayNote = note || stage.delayNote;
  stage.plannedDue = addDays(stage.plannedDue, delayDays);
  if (stage.status !== 'Completed') stage.status = 'Delayed';

  // Shift every downstream stage's planned start/due by the same number of days
  for (let i = idx + 1; i < scope.stages.length; i++) {
    scope.stages[i].plannedStart = addDays(scope.stages[i].plannedStart, delayDays);
    scope.stages[i].plannedDue = addDays(scope.stages[i].plannedDue, delayDays);
  }

  scope.projectedCompletion = scope.stages[scope.stages.length - 1].plannedDue;

  next.health = computeProjectHealth(next);
  next.changeLog = next.changeLog || [];
  next.changeLog.unshift({
    id: uid('log'),
    date: todayISO(),
    role: actingRole,
    user: actingUser,
    action: `Reported delay on ${scope.name}, stage "${stage.name}": ${delayDays} day${delayDays === 1 ? '' : 's'} (${reasonCategory}). Downstream stages shifted.`,
  });
  return next;
}

// ---------------------------------------------------------------------------
// Window & Exterior Door System dependency engine (§ Window Schedule
// template) — the graph-aware counterpart to applyDelayCascade above.
// Where that function shifts every stage at a higher ARRAY INDEX by a flat
// number of days, a window scope's nodes form a real DAG (Glass Approval
// runs parallel to Profile Approval; Factory Production needs all three
// approvals — a MAX-of-dependencies join, not a sum), so a delay here must
// only propagate to nodes actually downstream of the one that moved.
// ---------------------------------------------------------------------------

// Pure recompute: walks the DAG in dependency order and sets forecastStart/
// forecastEnd on every node from MAX(dependsOn.forecastEnd) (or the
// schedule's startDate for a root node), using the node's actual dates
// instead once they're recorded (an in-progress/finished node's real dates
// are always the better estimate for anything downstream — same principle
// productionDateRange already applies to scope.stages). Captures the
// baseline exactly once, the first time this ever runs for a given
// schedule (baseline.capturedDate is still null) — every later call, no
// matter how many delays/edits happen, leaves baseline untouched.
function computeWindowSchedule(windowSchedule) {
  const byId = {};
  windowSchedule.nodes.forEach(n => { byId[n.id] = n; });
  const order = [];
  const visited = new Set();
  function visit(n) {
    if (visited.has(n.id)) return;
    visited.add(n.id);
    n.dependsOn.forEach(depId => { if (byId[depId]) visit(byId[depId]); });
    order.push(n);
  }
  windowSchedule.nodes.forEach(visit);

  order.forEach(n => {
    let start;
    if (n.dependsOn.length === 0) {
      start = windowSchedule.startDate;
    } else {
      start = n.dependsOn.map(depId => byId[depId].forecastEnd).reduce((a, b) => (a > b ? a : b));
    }
    n.forecastStart = n.actualStart || start;
    n.forecastEnd = n.actualEnd || addDays(n.forecastStart, n.duration);
  });

  if (!windowSchedule.baseline.capturedDate) {
    windowSchedule.nodes.forEach(n => { n.baselineStart = n.forecastStart; n.baselineEnd = n.forecastEnd; });
    windowSchedule.baseline = {
      capturedDate: todayISO(),
      nodeSnapshots: windowSchedule.nodes.map(n => ({ nodeId: n.id, baselineStart: n.baselineStart, baselineEnd: n.baselineEnd })),
    };
  }
}

// Backtracks from the final node (the one nothing else depends on, latest
// forecastEnd if there's more than one) to a root, at each join following
// whichever dependency has the latest forecastEnd — the chain actually
// driving the final date. Simple backtrack-and-highlight, not a full
// slack/float computation (per the resolved design decision).
function identifyCriticalPath(windowSchedule) {
  const byId = {};
  windowSchedule.nodes.forEach(n => { byId[n.id] = n; });
  const dependedOn = new Set();
  windowSchedule.nodes.forEach(n => n.dependsOn.forEach(d => dependedOn.add(d)));
  const terminalNodes = windowSchedule.nodes.filter(n => !dependedOn.has(n.id));
  if (terminalNodes.length === 0) return [];
  let cur = terminalNodes.reduce((latest, n) => (!latest || (n.forecastEnd && n.forecastEnd > latest.forecastEnd)) ? n : latest, null);
  const path = [];
  while (cur) {
    path.unshift(cur.id);
    if (!cur.dependsOn.length) break;
    let next = null;
    cur.dependsOn.forEach(depId => {
      const dep = byId[depId];
      if (!next || (dep.forecastEnd && dep.forecastEnd > next.forecastEnd)) next = dep;
    });
    cur = next;
  }
  return path;
}

// Undo/audit snapshot for a scope's windowSchedule — same purpose as
// pushStageHistory, but for this structure. Snapshots everything except its
// own `history` array (which would otherwise nest a copy of itself inside
// each entry, growing without bound).
function pushWindowScheduleHistory(scope) {
  const ws = scope.windowSchedule;
  if (!ws) return;
  ws.history = ws.history || [];
  const snapshot = cloneDeep(ws);
  delete snapshot.history;
  ws.history.push(snapshot);
  if (ws.history.length > 20) ws.history.shift();
}

// Graph-aware analog of applyDelayCascade: extends the ONE node's duration
// (rather than editing its forecastEnd directly, so a later recompute stays
// duration-based/consistent) and re-runs computeWindowSchedule so only
// nodes whose dependency chain actually includes this one shift — a delay
// on Glass Approval, for instance, never touches Profile Approval's dates,
// unlike applyDelayCascade's blanket "shift everything after this index."
function applyWindowScheduleDelay(project, scopeId, nodeId, delayDays, reasonCategory, note, actingRole, actingUser) {
  const next = cloneDeep(project);
  const scope = findScope(next, scopeId);
  if (!scope || !scope.windowSchedule) return project;
  const node = scope.windowSchedule.nodes.find(n => n.id === nodeId);
  if (!node) return project;
  pushWindowScheduleHistory(scope);

  node.duration = (node.duration || 0) + delayDays;
  node.delayDays = (node.delayDays || 0) + delayDays;
  node.delayReason = reasonCategory;
  node.delayNote = note || node.delayNote;
  if (node.status !== 'Completed') node.status = 'Delayed';

  computeWindowSchedule(scope.windowSchedule);

  next.health = computeProjectHealth(next);
  next.changeLog = next.changeLog || [];
  next.changeLog.unshift({
    id: uid('log'), date: todayISO(), role: actingRole, user: actingUser,
    action: `Reported delay on ${scope.name}, Window Schedule node "${node.name}": ${delayDays} day${delayDays === 1 ? '' : 's'} (${reasonCategory}). Downstream nodes recalculated.`,
  });
  return next;
}

// Shared 4-tier status vocabulary + colors for the Window Schedule (unlike
// ScopesTimelineView/WorkloadGanttView/ProductionRecordGanttView, which each
// redefine their own status→color map inline, this one is exported once and
// reused by every Window Schedule UI so they can't drift apart).
// ---------------------------------------------------------------------------
// Schedule palette — shared by every Gantt, timeline and calendar
// ---------------------------------------------------------------------------
// The old flat earth tones read as one grey-brown mass at a glance, which is
// the opposite of what a schedule is for. These are brighter and clearly
// distinct from each other while staying in the brand's warm family, and each
// carries a gradient pair so bars have depth rather than looking like plain
// blocks. `text` is the accessible ink colour for a label sitting on the bar.
const SCHEDULE_COLORS = {
  Complete:     { from: '#34D399', to: '#059669', text: '#ffffff', soft: '#D1FAE5' },  // emerald
  'In Progress':{ from: '#FBBF24', to: '#D97706', text: '#3B2408', soft: '#FEF3C7' },  // amber
  Delayed:      { from: '#FB7185', to: '#E11D48', text: '#ffffff', soft: '#FFE4E6' },  // rose
  'At Risk':    { from: '#FB923C', to: '#EA580C', text: '#ffffff', soft: '#FFEDD5' },  // orange
  'On Track':   { from: '#38BDF8', to: '#0284C7', text: '#ffffff', soft: '#E0F2FE' },  // sky
  'Not Started':{ from: '#C7D2E0', to: '#94A3B8', text: '#334155', soft: '#F1F5F9' },  // slate
};
// A CSS gradient for a bar of the given status.
function scheduleGradient(status) {
  const c = SCHEDULE_COLORS[status] || SCHEDULE_COLORS['Not Started'];
  return `linear-gradient(135deg, ${c.from} 0%, ${c.to} 100%)`;
}
function scheduleColor(status, key) {
  const c = SCHEDULE_COLORS[status] || SCHEDULE_COLORS['Not Started'];
  return c[key || 'to'];
}

// Window Schedule keeps its own status vocabulary but now draws from the same
// palette, so the two schedule views no longer disagree about what green means.
const WINDOW_SCHEDULE_STATUS_COLORS = {
  'Complete': SCHEDULE_COLORS.Complete.to,
  Delayed: SCHEDULE_COLORS.Delayed.to,
  'At Risk': SCHEDULE_COLORS['At Risk'].to,
  'On Track': SCHEDULE_COLORS['On Track'].to,
  'Not Started': SCHEDULE_COLORS['Not Started'].to,
};
function deriveWindowScheduleStatus(node, today) {
  if (node.actualEnd) return 'Complete';
  if (!node.forecastEnd) return 'Not Started';
  const t = today || todayISO();
  if (node.forecastEnd < t) return 'Delayed';
  if (daysBetween(t, node.forecastEnd) <= 5) return 'At Risk';
  if (node.actualStart || t >= node.forecastStart) return 'On Track';
  return 'Not Started';
}

// Project-level counterpart to pushStageHistory/applyDelayCascade above —
// same mechanics, operating on project.chronology (shared once per
// project) instead of one scope's own stages array.
function pushChronologyHistory(project, stageId) {
  project.chronologyHistory = project.chronologyHistory || [];
  project.chronologyHistory.push({ stageId, stages: cloneDeep(project.chronology), projectedCompletion: project.chronologyProjectedCompletion || null });
  if (project.chronologyHistory.length > 20) project.chronologyHistory.shift();
}
function applyChronologyDelayCascade(project, stageId, delayDays, reasonCategory, note, actingRole, actingUser) {
  const next = cloneDeep(project);
  const idx = next.chronology.findIndex(s => s.id === stageId);
  if (idx === -1) return project;
  pushChronologyHistory(next, stageId);

  const stage = next.chronology[idx];
  stage.delayDays = (stage.delayDays || 0) + delayDays;
  stage.delayReason = reasonCategory;
  stage.delayNote = note || stage.delayNote;
  stage.plannedDue = addDays(stage.plannedDue, delayDays);
  if (stage.status !== 'Completed') stage.status = 'Delayed';

  for (let i = idx + 1; i < next.chronology.length; i++) {
    next.chronology[i].plannedStart = addDays(next.chronology[i].plannedStart, delayDays);
    next.chronology[i].plannedDue = addDays(next.chronology[i].plannedDue, delayDays);
  }
  next.chronologyProjectedCompletion = next.chronology[next.chronology.length - 1].plannedDue;

  next.health = computeProjectHealth(next);
  next.changeLog = next.changeLog || [];
  next.changeLog.unshift({
    id: uid('log'), date: todayISO(), role: actingRole, user: actingUser,
    action: `Reported delay on Lead, Take-Off & Quotes, stage "${stage.name}": ${delayDays} day${delayDays === 1 ? '' : 's'} (${reasonCategory}). Downstream stages shifted.`,
  });
  return next;
}

// Inserts one more round of Take-Off/Quote/Shop Drawing Revision into a
// scope's stage list (§ additional revision stages request). Always adds
// right after the LAST existing stage in that revision family (so repeated
// use stacks "Quote Revision", "Quote Revision 2", "Quote Revision 3"…),
// falling back to right after the family's anchor stage (Take-Off/Quote
// Preparation/Shop Drawings) the first time. Every stage after the
// insertion point shifts by the new stage's duration, mirroring the delay
// cascade above — this is a schedule change like any other, so it's
// snapshotted via pushStageHistory first and undoable the same way.
// `labelOverride` lets the caller name the round the way the scope's own
// template names it — a countertop's drawing round is a Submittal, so its
// revision has to be a Submittal Revision and not a Shop Drawing Revision.
function insertRevisionStage(project, scopeId, familyAnchorKey, actingRole, actingUser, labelOverride) {
  const base = REVISION_STAGE_FAMILIES[familyAnchorKey];
  if (!base) return project;
  const family = labelOverride ? { ...base, label: labelOverride } : base;
  const next = cloneDeep(project);
  // Take-Off/Quote families are project-level (chronology, shared across
  // every scope) — everything else (Shop Drawings) still belongs to the one
  // scope it was invoked on. `owner` is whichever array this insertion
  // actually operates on; `ownerLabel` only affects the change-log line.
  let owner, ownerLabel;
  if (family.level === 'project') {
    owner = next;
    ownerLabel = 'Lead, Take-Off & Quotes';
  } else {
    owner = findScope(next, scopeId);
    if (!owner) return project;
    ownerLabel = owner.name;
  }
  const stages = family.level === 'project' ? owner.chronology : owner.stages;

  const isFamilyStage = st => st.name === family.label || st.name.startsWith(family.label + ' ');
  let insertAfterIdx = -1;
  stages.forEach((st, i) => { if (isFamilyStage(st)) insertAfterIdx = i; });
  if (insertAfterIdx === -1) insertAfterIdx = stages.findIndex(st => st.key === familyAnchorKey);
  if (insertAfterIdx === -1) return project;

  if (family.level === 'project') pushChronologyHistory(next, stages[insertAfterIdx].id);
  else pushStageHistory(owner, stages[insertAfterIdx].id);

  const existingCount = stages.filter(isFamilyStage).length;
  const name = existingCount === 0 ? family.label : `${family.label} ${existingCount + 1}`;
  const anchor = stages[insertAfterIdx];
  const duration = family.baseDays;
  const plannedStart = anchor.actualCompletion || anchor.plannedDue;
  const newStage = {
    id: uid('stage'), key: `${familyAnchorKey}_revision_${existingCount + 1}`, name, order: insertAfterIdx + 1,
    responsibleRole: family.role, assignedUserId: null, duration,
    plannedStart, plannedDue: addDays(plannedStart, duration),
    actualStart: null, actualCompletion: null, status: 'Not Started',
    delayDays: 0, delayReason: null, delayNote: null,
  };
  stages.splice(insertAfterIdx + 1, 0, newStage);
  for (let i = insertAfterIdx + 2; i < stages.length; i++) {
    stages[i].plannedStart = addDays(stages[i].plannedStart, duration);
    stages[i].plannedDue = addDays(stages[i].plannedDue, duration);
  }
  stages.forEach((st, i) => { st.order = i; });
  if (family.level === 'project') next.chronologyProjectedCompletion = stages[stages.length - 1].plannedDue;
  else owner.projectedCompletion = stages[stages.length - 1].plannedDue;

  next.health = computeProjectHealth(next);
  next.changeLog = next.changeLog || [];
  next.changeLog.unshift({ id: uid('log'), date: todayISO(), role: actingRole, user: actingUser, action: `Added stage "${name}" to ${ownerLabel} — downstream stages shifted by ${duration} day${duration === 1 ? '' : 's'}.` });
  return next;
}

function scopeProjectedCompletion(scope) {
  if (scope.projectedCompletion) return scope.projectedCompletion;
  if (!scope.stages.length) return null;
  return scope.stages[scope.stages.length - 1].plannedDue;
}

function scopeIsComplete(scope) {
  return scope.stages.length > 0 && scope.stages.every(s => s.status === 'Completed');
}

function scopeHasOverdueOrDelayed(scope) {
  const today = todayISO();
  return scope.stages.some(s => {
    if (s.status === 'Completed') return false;
    if (s.status === 'Delayed') return true;
    return s.plannedDue < today;
  });
}

function scopeTotalDelayDays(scope) {
  return scope.stages.reduce((sum, s) => sum + (s.delayDays || 0), 0);
}

// The "floor" pipelineStatus implied by real chronology + scope progress —
// only ever used to ADVANCE a project's manually-set status forward when
// progress has outpaced it, never to downgrade one (so a manual "Lost Job"
// is never fought). Called wherever a chronology or scope stage completes.
function deriveMinimumPipelineStatus(project) {
  const chronology = project.chronology || [];
  const takeOffOrQuoteStarted = chronology.some(s => (s.key === 'take_off' || s.key === 'quote_prep') && s.status !== 'Not Started');
  const scopes = project.scopes || [];
  const anyContractReached = scopes.some(s => s.stages.some(st => st.key === 'contract' && st.status !== 'Not Started'));
  const allScopesComplete = scopes.length > 0 && scopes.every(scopeIsComplete);
  if (allScopesComplete) return 'Completed Job';
  if (anyContractReached) return 'Active Job';
  if (takeOffOrQuoteStarted) return 'Active Quotation';
  return 'Lead';
}

// Financial Hub Issues (Phase 7) — an open issue with a matching holdScope
// blocks the Delivery/Installation request-or-schedule actions checked
// against it, until Accounting resolves it. kind is 'Deliveries' or
// 'Installations'; a holdScope of 'Both' blocks either kind. Returns the
// blocking issue (or null).
function projectPaymentHold(project, kind) {
  return (project.financialIssues || []).find(i => i.status === 'Open' && (i.holdScope === kind || i.holdScope === 'Both')) || null;
}

// ---------------------------------------------------------------------------
// Project health indicator — Green / Yellow / Red
// Derived from delays, overdue tasks, and blocking (open, high-severity) issues.
// ---------------------------------------------------------------------------
function computeProjectHealth(project) {
  if (project.pipelineStatus === 'Lost Job') return 'Red';
  if (project.pipelineStatus === 'Lead' || project.pipelineStatus === 'Active Quotation') return 'Green';

  const today = todayISO();
  let redSignals = 0;
  let yellowSignals = 0;

  project.scopes.forEach(scope => {
    const totalDelay = scopeTotalDelayDays(scope);
    if (totalDelay >= 10) redSignals++;
    else if (totalDelay > 0) yellowSignals++;
    scope.stages.forEach(st => {
      if (st.status !== 'Completed' && st.plannedDue < today) redSignals++;
    });
  });

  (project.tasks || []).forEach(t => {
    if (t.status !== 'Completed' && t.dueDate < today) yellowSignals++;
  });

  (project.issues || []).forEach(i => {
    if (i.status === 'Open' && i.severity === 'High') redSignals++;
  });

  if (redSignals > 0) return 'Red';
  if (yellowSignals > 0) return 'Yellow';
  return 'Green';
}

// ---------------------------------------------------------------------------
// Financials — revised contract value, payment terms, profitability (§2)
// ---------------------------------------------------------------------------
function approvedChangeOrderTotal(project) {
  return (project.changeOrders || [])
    .filter(co => co.status === 'Approved')
    .reduce((sum, co) => sum + co.amount, 0);
}

function revisedContractValue(project) {
  return (project.originalContractValue || 0) + approvedChangeOrderTotal(project);
}

function paymentTermsWithAmounts(project) {
  const rcv = revisedContractValue(project);
  return (project.paymentTerms || []).map(pt => ({ ...pt, amount: rcv * (pt.pct / 100) }));
}

function collectedTotal(project) {
  return (project.paymentRequisitions || [])
    .filter(r => r.status === 'Approved')
    .reduce((sum, r) => sum + r.amount, 0);
}

function collectionPct(project) {
  const rcv = revisedContractValue(project);
  if (!rcv) return 0;
  return (collectedTotal(project) / rcv) * 100;
}

function payablesTotals(project) {
  let total = 0, paid = 0;
  (project.purchaseOrders || []).forEach(po => {
    total += po.amount;
    (po.paymentTerms || []).forEach(term => {
      if (term.status === 'Paid') paid += po.amount * (term.pct / 100);
    });
  });
  return { total, paid, pct: total ? (paid / total) * 100 : 0 };
}

function profitability(project) {
  const rcv = revisedContractValue(project);
  const estCost = project.estimatedCost || 0;
  const actCost = project.actualCostToDate || 0;
  const estMarginPct = rcv ? ((rcv - estCost) / rcv) * 100 : 0;
  const actMarginPct = rcv ? ((rcv - actCost) / rcv) * 100 : 0;
  const costVarianceAmt = actCost - estCost;
  const costVariancePct = estCost ? (costVarianceAmt / estCost) * 100 : 0;
  const payables = payablesTotals(project);
  return {
    revisedContractValue: rcv,
    originalContractValue: project.originalContractValue || 0,
    estimatedCost: estCost,
    actualCost: actCost,
    estMarginPct,
    actMarginPct,
    costVarianceAmt,
    costVariancePct,
    collectionPct: collectionPct(project),
    payablesPaidPct: payables.pct,
    payablesTotal: payables.total,
    payablesPaid: payables.paid,
  };
}

// ---------------------------------------------------------------------------
// Vendor estimate approvals -> auto-issue PO (§2, §5)
// ---------------------------------------------------------------------------
function approveVendorEstimate(project, veId, which, actingRole, actingUser, poNumber) {
  const next = cloneDeep(project);
  const ve = next.vendorEstimates.find(v => v.id === veId);
  if (!ve) return project;

  if (which === 'pm') {
    ve.pmApproved = true;
    ve.pmApprovedBy = actingUser;
    ve.pmApprovedDate = todayISO();
  } else if (which === 'owner') {
    ve.ownerApproved = true;
    ve.ownerApprovedBy = actingUser;
    ve.ownerApprovedDate = todayISO();
  }

  next.changeLog = next.changeLog || [];
  next.changeLog.unshift({
    id: uid('log'),
    date: todayISO(),
    role: actingRole,
    user: actingUser,
    action: `${which === 'pm' ? 'Project Management' : 'Ownership'} approved vendor estimate — ${ve.vendorName} (${fmtMoney(ve.amount)}).`,
  });

  if (!ve.status) ve.status = 'Received';
  if (ve.status !== 'Approved' && ve.status !== 'Converted to PO') ve.status = 'Approved';

  if (ve.pmApproved && ve.ownerApproved && !ve.poId) {
    const po = {
      id: uid('po'),
      poNumber,
      vendorEstimateId: ve.id,
      vendorId: ve.vendorId || null,
      vendorName: ve.vendorName,
      category: ve.category,
      scopeId: ve.scopeId || null,
      currency: ve.currency || 'USD',
      amount: ve.amount,
      issuedDate: todayISO(),
      status: 'Approved',
      revisions: [],
      deliveryTerms: '', requiredDate: null, notes: '',
      paymentTerms: [
        { id: uid('pot'), label: 'Deposit', pct: 50, status: 'Not Due' },
        { id: uid('pot'), label: 'Balance on Delivery', pct: 50, status: 'Not Due' },
      ],
    };
    next.purchaseOrders = next.purchaseOrders || [];
    next.purchaseOrders.push(po);
    ve.poId = po.id;
    ve.status = 'Converted to PO';
    next.changeLog.unshift({
      id: uid('log'),
      date: todayISO(),
      role: actingRole,
      user: actingUser,
      action: `Both approvals received for ${ve.vendorName} — PO ${poNumber} auto-issued (${fmtMoney(ve.amount)}) with default payment terms. Estimate ${ve.estimateNumber || ''} converted to PO.`,
    });
  }

  next.health = computeProjectHealth(next);
  return next;
}

// ---------------------------------------------------------------------------
// Freight estimates -> Freight PO (mirrors vendor estimate two-approval
// pattern, but the two approvals are Export Manager + Admin/Accounting)
// ---------------------------------------------------------------------------
function approveFreightEstimate(project, freId, which, actingRole, actingUser, poNumber) {
  const next = cloneDeep(project);
  const fre = next.freightEstimates.find(f => f.id === freId);
  if (!fre) return project;

  if (which === 'export') {
    fre.exportApproved = true;
    fre.exportApprovedBy = actingUser;
    fre.exportApprovedDate = todayISO();
  } else if (which === 'admin') {
    fre.adminApproved = true;
    fre.adminApprovedBy = actingUser;
    fre.adminApprovedDate = todayISO();
  }

  next.changeLog = next.changeLog || [];
  next.changeLog.unshift({
    id: uid('log'), date: todayISO(), role: actingRole, user: actingUser,
    action: `${which === 'export' ? 'Export Manager' : 'Admin'} approved freight estimate — ${fre.carrier} (${fmtMoney(fre.amount)}).`,
  });

  if (!fre.status) fre.status = 'Received';
  if (fre.status !== 'Approved' && fre.status !== 'Converted to PO') fre.status = 'Approved';

  if (fre.exportApproved && fre.adminApproved && !fre.poId) {
    const po = {
      id: uid('fpo'), poNumber, freightEstimateId: fre.id, carrier: fre.carrier, category: fre.category,
      scopeId: fre.scopeId || null, currency: fre.currency || 'USD', amount: fre.amount, issuedDate: todayISO(),
      status: 'Approved', revisions: [], deliveryTerms: '', requiredDate: null, notes: '',
    };
    next.freightPOs = next.freightPOs || [];
    next.freightPOs.push(po);
    fre.poId = po.id;
    fre.status = 'Converted to PO';
    next.changeLog.unshift({
      id: uid('log'), date: todayISO(), role: actingRole, user: actingUser,
      action: `Both approvals received for freight estimate — ${fre.carrier}. Freight PO ${poNumber} auto-issued (${fmtMoney(fre.amount)}). Estimate ${fre.estimateNumber || ''} converted to PO.`,
    });
  }
  return next;
}

// ---------------------------------------------------------------------------
// Shop-drawing lead time gate: production stages cannot start until the
// signed Contract is on file and the Deposit payment term is Paid.
// ---------------------------------------------------------------------------
function canStartStage(project, stage) {
  if (stage.key !== 'shop_drawings') return { ok: true };
  const hasContract = (project.documents || []).some(d => d.type === 'Contract');
  const depositTerm = (project.paymentTerms || []).find(t => /deposit/i.test(t.label));
  const depositPaid = depositTerm && depositTerm.status === 'Paid';
  if (!hasContract || !depositPaid) {
    return { ok: false, reason: 'Shop Drawings cannot start until the signed Contract is on file (Documents/Quotes & Contracts) and the Deposit payment term is marked Paid (Financials).' };
  }
  return { ok: true };
}

// ---------------------------------------------------------------------------
// Field issue auto-numbering, e.g. "366-KIT-FI-0047"
// ---------------------------------------------------------------------------
function scopeAbbrev(scope) {
  if (!scope) return 'GEN';
  return scope.name.replace(/[^A-Za-z]/g, '').slice(0, 3).toUpperCase() || 'GEN';
}
function generateFieldIssueNumber(project, scope, sequence) {
  const projNum = (project.projectNumber || '').replace(/\D/g, '').slice(-3) || '000';
  return `${projNum}-${scopeAbbrev(scope)}-FI-${String(sequence).padStart(4, '0')}`;
}

// ---------------------------------------------------------------------------
// Retainage rollups
// ---------------------------------------------------------------------------
function totalRetainageHeld(project) {
  return (project.paymentRequisitions || []).reduce((sum, r) => sum + (r.retainageHeld || 0), 0);
}

// ---------------------------------------------------------------------------
// Acceptance rate — decided opportunities (Active Job vs Lost Job) that
// converted to a job. Leads/Active Quotation are still undecided, excluded.
// ---------------------------------------------------------------------------
function acceptanceRate(projects) {
  const won = projects.filter(p => p.pipelineStatus === 'Active Job' || p.pipelineStatus === 'Completed Job').length;
  const lost = projects.filter(p => p.pipelineStatus === 'Lost Job').length;
  const decided = won + lost;
  return { won, lost, decided, pct: decided ? (won / decided) * 100 : null };
}

// Vendor/forwarder bid acceptance rate — estimates that converted into an
// issued PO vs all estimates ever submitted to them.
function vendorAcceptanceRate(estimates) {
  const total = estimates.length;
  const accepted = estimates.filter(e => e.poId).length;
  return { total, accepted, pct: total ? (accepted / total) * 100 : null };
}

// ---------------------------------------------------------------------------
// Sales / quotation financial rollups (§5) — a Lost Job's value must never
// count toward Contract Value, Awarded Revenue, Active Project Value,
// Backlog, or Forecasted Contract Revenue, but is still tracked and reported
// on separately so Administration can see win/loss rate.
// ---------------------------------------------------------------------------
function latestQuoteAmount(project) {
  const revs = project.quoteRevisions || [];
  if (revs.length === 0) return 0;
  return revs.reduce((best, r) => (r.revision >= best.revision ? r : best), revs[0]).amount;
}
function salesValueSummary(projects) {
  let totalQuoted = 0, activeQuotationValue = 0, awardedValue = 0, contractValue = 0, lostQuotationValue = 0;
  let numQuotations = 0, awardedCount = 0, lostCount = 0;
  projects.forEach(p => {
    const amt = latestQuoteAmount(p);
    if ((p.quoteRevisions || []).length) numQuotations++;
    totalQuoted += amt;
    if (p.pipelineStatus === 'Active Quotation') activeQuotationValue += amt;
    if (p.pipelineStatus === 'Active Job' || p.pipelineStatus === 'Completed Job') { awardedValue += amt; contractValue += revisedContractValue(p); awardedCount++; }
    if (p.pipelineStatus === 'Lost Job') { lostQuotationValue += amt; lostCount++; }
  });
  const decided = awardedCount + lostCount;
  return {
    totalQuoted, activeQuotationValue, awardedValue, contractValue, lostQuotationValue,
    numQuotations, awardedCount, lostCount,
    winRate: decided ? (awardedCount / decided) * 100 : null,
    lossRate: decided ? (lostCount / decided) * 100 : null,
  };
}

// ---------------------------------------------------------------------------
// Unplanned business cost / potential loss (§9)
// ---------------------------------------------------------------------------
function unplannedCostItems(project) {
  const fromEstimates = (project.vendorEstimates || []).filter(v => isUnplannedCost(v.category));
  // Miscellaneous invoices (§ misc invoice request) are unplanned by
  // definition — they never went through a vendor estimate at all — so they
  // fold into the same report here rather than a separate one. Reshaped to
  // the same {id, vendorName, unplannedReason, category, amount,
  // recoverability} fields the render below (ProfitabilityTab, app.jsx)
  // already expects from a vendor estimate.
  const fromMisc = (project.apInvoices || []).filter(i => apInvoiceLive(i) && i.partyType === 'Miscellaneous').map(i => ({
    id: i.id, vendorName: i.vendorName, unplannedReason: null, category: i.expenseCategory || 'Miscellaneous',
    amount: i.amount, recoverability: i.recoverability || 'Pending Determination',
  }));
  return [...fromEstimates, ...fromMisc];
}
function unplannedCostSummary(project) {
  const items = unplannedCostItems(project);
  return {
    items,
    totalUnplanned: items.reduce((s, v) => s + v.amount, 0),
    businessLoss: items.filter(v => v.recoverability === 'Business Loss').reduce((s, v) => s + v.amount, 0),
  };
}

// ---------------------------------------------------------------------------
// Projected Profitability (§6-8, §10) — per-scope cost breakdown rolled up
// to the project, with an immutable baseline snapshot for projected-vs-
// actual-vs-variance comparison once a project is contracted.
// ---------------------------------------------------------------------------
function scopeProfitabilityCalc(scope) {
  const p = scope.profitability;
  const totalProjectedCost = PROFIT_COST_FIELDS.reduce((s, f) => s + (p.costs[f.key] || 0), 0);
  const projectedProfit = (p.salesValue || 0) - totalProjectedCost;
  const projectedMarginPct = p.salesValue ? (projectedProfit / p.salesValue) * 100 : 0;
  return {
    totalProjectedCost, projectedProfit, projectedMarginPct,
    belowTarget: p.salesValue > 0 && projectedMarginPct < (p.targetMarginPct || 0),
  };
}
function scopeActualTotalCost(scope) {
  const a = scope.profitability.actual;
  return PROFIT_COST_FIELDS.reduce((s, f) => s + (a[f.key] || 0), 0);
}
function projectProfitabilitySummary(project) {
  const rows = project.scopes.map(scope => ({ scope, ...scopeProfitabilityCalc(scope) }));
  const totalSales = rows.reduce((s, r) => s + (r.scope.profitability.salesValue || 0), 0);
  const totalCost = rows.reduce((s, r) => s + r.totalProjectedCost, 0);
  const totalProfit = totalSales - totalCost;
  return { rows, totalSales, totalCost, totalProfit, overallMarginPct: totalSales ? (totalProfit / totalSales) * 100 : 0 };
}
// Projected (baseline) vs Actual vs Variance, grouped exactly as requested:
// Vendor Cost, Freight, Tariffs/Duties, Installation, Overhead, Other Costs,
// Profit, Margin %. Returns null until a baseline has been locked in.
function scopeVarianceRows(scope) {
  const p = scope.profitability;
  const baseline = p.baseline;
  if (!baseline) return null;
  const actual = p.actual;
  const baselineTotalCost = PROFIT_COST_FIELDS.reduce((s, f) => s + (baseline.costs[f.key] || 0), 0);
  const actualTotalCost = scopeActualTotalCost(scope);
  const baselineProfit = baseline.salesValue - baselineTotalCost;
  const actualProfit = (p.salesValue || 0) - actualTotalCost;
  const baselineMargin = baseline.salesValue ? (baselineProfit / baseline.salesValue) * 100 : 0;
  const actualMargin = p.salesValue ? (actualProfit / p.salesValue) * 100 : 0;
  const lines = [
    { label: 'Vendor Cost', baseline: baseline.costs.vendorCost, actual: actual.vendorCost },
    { label: 'Freight', baseline: baseline.costs.oceanFreight + baseline.costs.domesticFreight, actual: actual.oceanFreight + actual.domesticFreight },
    { label: 'Tariffs / Duties', baseline: baseline.costs.tariffs + baseline.costs.dutiesCustoms, actual: actual.tariffs + actual.dutiesCustoms },
    { label: 'Installation', baseline: baseline.costs.installation, actual: actual.installation },
    { label: 'Overhead', baseline: baseline.costs.overhead, actual: actual.overhead },
    { label: 'Other Costs', baseline: baseline.costs.other + baseline.costs.warehousing, actual: actual.other + actual.warehousing },
    { label: 'Profit', baseline: baselineProfit, actual: actualProfit },
    { label: 'Margin %', baseline: baselineMargin, actual: actualMargin, isPct: true },
  ];
  return lines.map(l => ({ ...l, variance: l.actual - l.baseline }));
}

// ---------------------------------------------------------------------------
// Project P&L (§10) — Contract Revenue + Approved Change Orders vs Actual
// Project Cost, with unplanned business loss broken out separately so
// Administration can see not just whether a project was profitable but why
// margin was lost.
// ---------------------------------------------------------------------------
function projectPL(project) {
  const revenue = revisedContractValue(project);
  const budgetedCost = project.scopes.reduce((s, sc) => s + scopeProfitabilityCalc(sc).totalProjectedCost, 0);
  const actualCost = project.scopes.reduce((s, sc) => s + scopeActualTotalCost(sc), 0);
  const unplanned = unplannedCostSummary(project);
  const projectedProfit = revenue - budgetedCost;
  const actualProfit = revenue - actualCost;
  return {
    revenue, budgetedCost, actualCost, projectedProfit, actualProfit,
    projectedMarginPct: revenue ? (projectedProfit / revenue) * 100 : 0,
    actualMarginPct: revenue ? (actualProfit / revenue) * 100 : 0,
    variance: actualProfit - projectedProfit,
    unplannedLoss: unplanned.businessLoss,
    unplannedTotal: unplanned.totalUnplanned,
  };
}

// ---------------------------------------------------------------------------
// Accounts Payable (§ vendor-billing-AP request) — one connected payable
// workflow across Vendor, Freight, and Subcontractor invoices: Approval
// Status and Payment Status are always tracked as two separate fields, and
// every payment is appended (never overwritten) so open balance and history
// stay accurate no matter how many partial payments come in.
// ---------------------------------------------------------------------------
function allApInvoices(projects) {
  return projects.flatMap(p => liveApInvoices(p).map(inv => ({ ...inv, projectName: p.name, projectId: p.id })));
}
function invoiceTotalPaid(inv) {
  return (inv.payments || []).reduce((s, p) => s + p.amount, 0);
}
function invoiceOpenBalance(inv) {
  return inv.amount - invoiceTotalPaid(inv);
}
function invoiceIsPastDue(inv) {
  return !!inv.dueDate && inv.dueDate < todayISO() && invoiceOpenBalance(inv) > 0 && !['Cancelled', 'Paid'].includes(inv.paymentStatus);
}
function invoiceDaysPastDue(inv) {
  return invoiceIsPastDue(inv) ? daysBetween(inv.dueDate, todayISO()) : 0;
}
// Shared shape for Vendor / Subcontractor / Freight "financial summary"
// blocks — same structure, different filter, so the account page never
// needs a separate report just to see what's owed.
function partyFinancialSummary(invoices) {
  const totalInvoiced = invoices.reduce((s, i) => s + i.amount, 0);
  const totalPaid = invoices.reduce((s, i) => s + invoiceTotalPaid(i), 0);
  const openInvoices = invoices.filter(i => invoiceOpenBalance(i) > 0 && i.paymentStatus !== 'Cancelled');
  const pastDue = invoices.filter(invoiceIsPastDue);
  const submitted = invoices.filter(i => i.approvalStatus !== 'Draft');
  const approved = invoices.filter(i => ['Approved', 'Sent to Accounting'].includes(i.approvalStatus) || i.paymentStatus === 'Paid' || i.paymentStatus === 'Partially Paid');
  const rejectedOrRevision = invoices.filter(i => ['Rejected', 'Revision Requested'].includes(i.approvalStatus));
  const nextDue = openInvoices.map(i => i.dueDate).filter(Boolean).sort()[0] || null;
  return {
    totalInvoiced, totalPaid,
    totalOpenBalance: totalInvoiced - totalPaid,
    totalPastDue: pastDue.reduce((s, i) => s + invoiceOpenBalance(i), 0),
    openCount: openInvoices.length, pastDueCount: pastDue.length,
    totalSubmitted: submitted.reduce((s, i) => s + i.amount, 0),
    totalApproved: approved.reduce((s, i) => s + i.amount, 0),
    rejectedRevisionCount: rejectedOrRevision.length,
    nextPaymentDue: nextDue,
    openInvoices, invoices,
  };
}
function apDashboardSummary(invoices) {
  const today = todayISO();
  const weekAhead = addDays(today, 7);
  const monthAhead = addDays(today, 30);
  const open = invoices.filter(i => invoiceOpenBalance(i) > 0 && i.paymentStatus !== 'Cancelled');
  return {
    totalOpenBills: open.length,
    totalOpenBalance: open.reduce((s, i) => s + invoiceOpenBalance(i), 0),
    totalPastDue: open.filter(invoiceIsPastDue).reduce((s, i) => s + invoiceOpenBalance(i), 0),
    dueThisWeek: open.filter(i => i.dueDate && i.dueDate >= today && i.dueDate <= weekAhead),
    dueNext30: open.filter(i => i.dueDate && i.dueDate >= today && i.dueDate <= monthAhead),
    partiallyPaid: invoices.filter(i => i.paymentStatus === 'Partially Paid'),
    approvedWaitingPayment: invoices.filter(i => i.approvalStatus === 'Approved' && i.paymentStatus === 'Unpaid'),
    pendingApproval: invoices.filter(i => ['Submitted', 'Pending Approval'].includes(i.approvalStatus)),
    disputedOnHold: invoices.filter(i => ['On Hold', 'Disputed'].includes(i.paymentStatus)),
  };
}

// ============================================================================
// AIA-style billing engine — G702/G703 progress billing (§ AIA Billing
// redesign). Money is handled in plain JS numbers rounded to cents at each
// assembled total (roundMoney below) rather than mid-formula — a practical
// decimal-safety measure short of a full integer-cents rewrite, which would
// touch money handling across the entire app, not just billing.
// ============================================================================

function roundMoney(n) { return Math.round((Number(n) || 0) * 100) / 100; }

// Formula symbols: S = scheduled, P = previous, F = stored, R = remaining
// (S - P - F). Supports +,-,*,/,(),% and the four symbols only — anything
// else fails safe to 0 rather than risk evaluating arbitrary input.
function evalBillingFormula(formulaText, S, P, F) {
  if (!formulaText) return 0;
  const R = S - P - F;
  let expr = String(formulaText).replace(/^=/, '');
  expr = expr.replace(/(\d+(\.\d+)?)%/g, '($1/100)');
  expr = expr.replace(/S/g, `(${S})`).replace(/P/g, `(${P})`).replace(/F/g, `(${F})`).replace(/R/g, `(${R})`);
  if (!/^[0-9+\-*/().\s]*$/.test(expr)) return 0;
  try {
    // eslint-disable-next-line no-new-func
    const val = Function(`"use strict"; return (${expr});`)();
    return isFinite(val) ? val : 0;
  } catch (e) {
    return 0;
  }
}

// Resolves one G703 line for the current application period.
// D (previous) = cumulative INSTALLED work only, carried forward by
// createNextApplication — never includes still-uninstalled stored material.
// F (materials presently stored) is its own carried-forward balance
// (storedPreviousBalance), moved by two period-scoped user inputs:
//   storedIncorporated — value of previously-stored material built into the
//     work this period (transfers OUT of the stored balance, INTO E, so it's
//     never counted in both buckets at once);
//   storedAdded — new material delivered/stored this period (transfers IN).
// E (work completed this period) = the line's own input (Amount/Percent/
// Formula) PLUS storedIncorporated, since AIA counts incorporated material
// as completed work the period it's built in.
// G = D + E + F, exactly the AIA formula, with E and F now correctly
// disjoint. scheduled may be negative for a Back Charge extra-work line —
// the "cannot exceed scheduled" clamp is skipped in that case.
function computeBillingLine(scheduled, line, retainagePct, retainageReleased) {
  const S = scheduled;
  const P = line.previous || 0;
  const storedPreviousBalance = line.storedPreviousBalance || 0;
  const storedIncorporated = Math.min(Math.max(Number(line.storedIncorporated) || 0, 0), storedPreviousBalance);
  const storedAdded = Math.max(Number(line.storedAdded) || 0, 0);
  const F = Math.max(0, storedPreviousBalance - storedIncorporated + storedAdded);
  let workCurrent = 0;
  if (line.inputType === 'Formula') {
    workCurrent = evalBillingFormula(line.formulaText, S, P, F);
  } else if (line.inputType === 'Percent') {
    workCurrent = S * ((Number(line.percent) || 0) / 100) - P;
  } else {
    workCurrent = Number(line.current) || 0;
  }
  let current = workCurrent + storedIncorporated;
  if (S >= 0) {
    current = Math.max(0, Math.min(current, S - P - F));
  }
  const completed = P + current + F;
  const rate = line.retainageOverride !== null && line.retainageOverride !== undefined ? line.retainageOverride / 100 : retainagePct / 100;
  // Distinguished per spec §5 even though both currently share one rate
  // (this app has no separate stored-materials retainage % input yet) —
  // the two figures are structurally kept apart so a future differing rate
  // is a one-line change, not a data-model rework.
  const retainageOnWork = retainageReleased ? 0 : rate * (completed - F);
  const retainageOnStored = retainageReleased ? 0 : rate * F;
  const retainage = retainageOnWork + retainageOnStored;
  const balance = S - completed;
  const pctComplete = S !== 0 ? (completed / S) * 100 : 0;
  return {
    scheduled: S, previous: P, current, workCurrent,
    storedPreviousBalance, storedIncorporated, storedAdded, stored: F,
    completed, retainage, retainageOnWork, retainageOnStored, balance, pctComplete,
  };
}

// Full application summary: per-line results plus the G702 roll-up totals.
// Original Contract / Net Changes now read the SAME authoritative figures
// as the Contract tab and AR (project.originalContractValue,
// approvedChangeOrderTotal) instead of recomputing from the SOV/CO lists —
// the SOV's own line-sum is still surfaced (sovLineTotal) so a mismatch
// between "what the SOV lines add up to" and "the executed contract value"
// shows up as a reconciliation finding (applicationValidation) rather than
// silently picking one number over the other.
function applicationSummary(project, applicationId) {
  const app = (project.applications || []).find(a => a.id === applicationId);
  if (!app) return null;
  const retainagePct = project.retainagePct || 0;
  const allItems = (project.sov || []).flatMap(c => c.items.map(i => ({ ...i, categoryName: c.name })));
  const blankLine = { previous: 0, current: 0, storedPreviousBalance: 0, storedIncorporated: 0, storedAdded: 0, inputType: 'Amount' };
  const lineResults = allItems.map(item => ({
    item, ...computeBillingLine(item.scheduledValue, app.lines[item.id] || blankLine, retainagePct, app.retainageReleased),
  }));
  const approvedCOs = (project.changeOrders || []).filter(co => co.status === 'Approved');
  const coLineResults = approvedCOs.map(co => ({
    co, ...computeBillingLine(co.amount, app.coLines[co.id] || blankLine, retainagePct, app.retainageReleased),
  }));

  const sovLineTotal = roundMoney(allItems.reduce((s, i) => s + i.scheduledValue, 0));
  const originalContract = roundMoney(project.originalContractValue || 0);
  const netChanges = roundMoney(approvedChangeOrderTotal(project));
  const totalContract = roundMoney(originalContract + netChanges);
  const completedToDate = roundMoney([...lineResults, ...coLineResults].reduce((s, r) => s + r.completed, 0));
  const retainage = roundMoney([...lineResults, ...coLineResults].reduce((s, r) => s + r.retainage, 0));
  const retainageOnWork = roundMoney([...lineResults, ...coLineResults].reduce((s, r) => s + r.retainageOnWork, 0));
  const retainageOnStored = roundMoney([...lineResults, ...coLineResults].reduce((s, r) => s + r.retainageOnStored, 0));
  const completedLessRetainage = roundMoney(completedToDate - retainage);

  // Previous applications' net due — the actual certified amount of the
  // prior application when one exists (real cumulative history), falling
  // back to the formulaic estimate only for the very first application.
  const apps = project.applications || [];
  const idx = apps.findIndex(a => a.id === applicationId);
  const priorApp = idx > 0 ? apps[idx - 1] : null;
  const previousCompleted = [...lineResults, ...coLineResults].reduce((s, r) => s + r.previous, 0);
  const previousRetainage = app.retainageReleased ? 0 : previousCompleted * (retainagePct / 100);
  const previousApplications = roundMoney(priorApp && priorApp.certifiedAmount !== null && priorApp.certifiedAmount !== undefined
    ? priorApp.certifiedAmount
    : previousCompleted - previousRetainage);

  const currentPaymentDue = roundMoney(completedLessRetainage - previousApplications);
  const balanceToFinish = roundMoney(totalContract - completedLessRetainage);

  // Extra Work Summary (Additions/Deletions, split Prev Applications vs.
  // This Application) — a Change Order counts as "previous" once it already
  // appeared on the prior application's coLines; otherwise it's newly
  // incorporated this cycle. Matches the client's own AIA template exactly.
  const priorCoIds = new Set(priorApp ? Object.keys(priorApp.coLines || {}) : []);
  const extraWork = {
    additionsPrev: roundMoney(coLineResults.filter(r => priorCoIds.has(r.co.id) && r.co.amount > 0).reduce((s, r) => s + r.co.amount, 0)),
    deletionsPrev: roundMoney(coLineResults.filter(r => priorCoIds.has(r.co.id) && r.co.amount < 0).reduce((s, r) => s + Math.abs(r.co.amount), 0)),
    additionsThis: roundMoney(coLineResults.filter(r => !priorCoIds.has(r.co.id) && r.co.amount > 0).reduce((s, r) => s + r.co.amount, 0)),
    deletionsThis: roundMoney(coLineResults.filter(r => !priorCoIds.has(r.co.id) && r.co.amount < 0).reduce((s, r) => s + Math.abs(r.co.amount), 0)),
  };
  extraWork.additionsTotal = roundMoney(extraWork.additionsPrev + extraWork.additionsThis);
  extraWork.deletionsTotal = roundMoney(extraWork.deletionsPrev + extraWork.deletionsThis);

  return {
    app, lineResults, coLineResults,
    sovLineTotal, originalContract, netChanges, totalContract, completedToDate, retainage,
    retainageOnWork, retainageOnStored, extraWork,
    completedLessRetainage, previousApplications, currentPaymentDue, balanceToFinish,
  };
}

// The full Section 8 pre-submission checklist. Billing against an
// unapproved Change Order is prevented by construction (coLineResults is
// built only from approvedCOs — an unapproved CO has no billable line to
// begin with), so it isn't a runtime check here.
function applicationValidation(summary, project) {
  const blocking = [];
  const warnings = [];
  const EPS = 0.01;
  if (Math.abs(summary.sovLineTotal - summary.originalContract) > EPS) {
    warnings.push(`Schedule of Values lines total ${fmtMoney(summary.sovLineTotal)}, which does not match the executed Original Contract Value of ${fmtMoney(summary.originalContract)}. Use "Sync from Contract" to reconcile.`);
  }
  if (summary.completedToDate > summary.totalContract + EPS) {
    blocking.push(`Total Completed & Stored to Date (${fmtMoney(summary.completedToDate)}) exceeds the revised Contract Sum to Date (${fmtMoney(summary.totalContract)}).`);
  }
  summary.lineResults.concat(summary.coLineResults).forEach(r => {
    const label = r.item ? r.item.description : `${r.co.number} — ${r.co.description}`;
    if (r.scheduled >= 0 && r.completed > r.scheduled + EPS) {
      blocking.push(`"${label}" is billed to ${fmtMoney(r.completed)}, over its scheduled value of ${fmtMoney(r.scheduled)}.`);
    }
  });
  if (summary.retainage > summary.completedToDate + EPS) {
    blocking.push('Total retainage exceeds Total Completed & Stored to Date — retainage calculation is inconsistent.');
  }
  const app = summary.app;
  const header = app.header || {};
  if (!header.ownerCompany) warnings.push('Header field "Owner" is not filled in (Contacts tab).');
  if (!header.architectCompany) warnings.push('Header field "Architect" is not filled in (Contacts tab).');
  if (!app.periodTo) blocking.push('Application has no billing period end date.');
  const apps = project.applications || [];
  const idx = apps.findIndex(a => a.id === app.id);
  if (idx > 0 && app.periodTo && apps[idx - 1].periodTo && app.periodTo < apps[idx - 1].periodTo) {
    blocking.push(`Period Ending (${app.periodTo}) is before the previous Application's period (${apps[idx - 1].periodTo}).`);
  }
  return { blocking, warnings, ok: blocking.length === 0 };
}

// Builds/refreshes the Schedule of Values from the executed contract: one
// line per scope (from scope.profitability.salesValue) plus one line per
// approved Change-Order-type entry (Back Charges are excluded — they're
// deductive against a vendor, not additional billable contract scope).
// Returns a diff for the confirmation UI rather than mutating — lines are
// matched by sourceType+sourceId, never by name, so a renamed scope doesn't
// look like a new line. Existing manually-entered lines (sourceType null,
// grandfathered from before this feature existed) are left untouched.
function planSovSyncFromContract(project) {
  const existingByKey = new Map();
  (project.sov || []).forEach(cat => cat.items.forEach(item => {
    if (item.sourceType) existingByKey.set(`${item.sourceType}:${item.sourceId}`, item);
  }));
  const toAdd = [];
  const toUpdate = [];
  (project.scopes || []).forEach(scope => {
    const value = (scope.profitability && scope.profitability.salesValue) || 0;
    const key = `scope:${scope.id}`;
    const existing = existingByKey.get(key);
    if (!existing) toAdd.push({ sourceType: 'scope', sourceId: scope.id, description: scope.name, scheduledValue: value });
    else if (Math.abs(existing.scheduledValue - value) > 0.01) toUpdate.push({ item: existing, newValue: value, description: scope.name });
  });
  (project.changeOrders || []).filter(co => co.type === 'Change Order' && co.status === 'Approved').forEach(co => {
    const value = co.amount || 0;
    const key = `changeOrder:${co.id}`;
    const existing = existingByKey.get(key);
    const desc = `${co.number || 'CO'} — ${co.description}`;
    if (!existing) toAdd.push({ sourceType: 'changeOrder', sourceId: co.id, description: desc, scheduledValue: value });
    else if (Math.abs(existing.scheduledValue - value) > 0.01 || existing.description !== desc) toUpdate.push({ item: existing, newValue: value, description: desc });
  });
  return { toAdd, toUpdate };
}

// Rolls a completed application forward into the next billing cycle.
// previous (D) becomes cumulative INSTALLED work only (P + current from the
// last period, excluding stored); the stored balance (F) carries forward
// separately as storedPreviousBalance so it keeps being tracked as its own
// line item rather than being folded permanently into "completed" the
// moment a new application opens.
function createNextApplication(project, periodTo, preparedBy, actingRole, actingUser) {
  const next = cloneDeep(project);
  const apps = next.applications || [];
  const last = apps[apps.length - 1];
  const number = last ? last.number + 1 : 1;

  const retainagePct = next.retainagePct || 0;
  const allItemIds = (next.sov || []).flatMap(c => c.items.map(i => i.id));
  const approvedCoIds = (next.changeOrders || []).filter(co => co.status === 'Approved').map(co => co.id);
  const blankLine = { previous: 0, current: 0, storedPreviousBalance: 0, storedIncorporated: 0, storedAdded: 0, inputType: 'Amount' };

  function rolledLine(scheduledValue, prevLine) {
    const resolved = computeBillingLine(scheduledValue, prevLine, retainagePct, last ? last.retainageReleased : false);
    return {
      previous: resolved.previous + resolved.current, storedPreviousBalance: resolved.stored,
      current: 0, storedIncorporated: 0, storedAdded: 0,
      inputType: 'Amount', formulaText: '', percent: 0, retainageOverride: prevLine.retainageOverride ?? null,
    };
  }
  const newLines = {};
  allItemIds.forEach(id => {
    const item = next.sov.flatMap(c => c.items).find(i => i.id === id);
    newLines[id] = rolledLine(item.scheduledValue, (last && last.lines[id]) || { ...blankLine, retainageOverride: null });
  });
  const newCoLines = {};
  approvedCoIds.forEach(id => {
    const co = next.changeOrders.find(c => c.id === id);
    newCoLines[id] = rolledLine(co.amount, (last && last.coLines[id]) || { ...blankLine, retainageOverride: null });
  });

  const app = {
    id: uid('app'), number, periodTo, preparedBy, status: 'Draft', retainageReleased: false,
    lines: newLines, coLines: newCoLines,
    payment: { status: 'Unpaid', amount: 0, reference: '', date: null },
    certifiedAmount: null, createdDate: todayISO(),
    header: {
      ...(next.aiaHeaderDefaults || {}),
      ownerCompany: ((next.contacts || {}).Owner || {}).company || '',
      ownerPerson: ((next.contacts || {}).Owner || {}).person || '',
      architectCompany: ((next.contacts || {}).Architect || {}).company || '',
      architectPerson: ((next.contacts || {}).Architect || {}).person || '',
    },
    history: [], voidReason: null,
  };
  next.applications = [...apps, app];
  next.changeLog = next.changeLog || [];
  next.changeLog.unshift({ id: uid('log'), date: todayISO(), role: actingRole, user: actingUser, action: `Created Payment Application ${number} (period ending ${periodTo}).` });
  return next;
}

function setApplicationRetainageReleased(project, applicationId, released, actingRole, actingUser) {
  const next = cloneDeep(project);
  const app = next.applications.find(a => a.id === applicationId);
  app.retainageReleased = released;
  next.changeLog = next.changeLog || [];
  next.changeLog.unshift({ id: uid('log'), date: todayISO(), role: actingRole, user: actingUser, action: `${released ? 'Released' : 'Resumed'} retainage on Application ${app.number}.` });
  return next;
}

function setApplicationPayment(project, applicationId, paymentData, currentPaymentDue, actingRole, actingUser) {
  const next = cloneDeep(project);
  const app = next.applications.find(a => a.id === applicationId);
  app.payment = { ...paymentData };
  if (paymentData.status === 'Paid') {
    app.status = 'Paid';
  } else if (paymentData.status === 'Partially Paid') {
    app.status = 'Partially Paid';
  } else if (paymentData.status === 'Unpaid') {
    app.payment.amount = 0;
    app.payment.reference = '';
    app.payment.date = null;
  }
  next.changeLog = next.changeLog || [];
  next.changeLog.unshift({ id: uid('log'), date: todayISO(), role: actingRole, user: actingUser, action: `Application ${app.number} payment status set to ${paymentData.status}.` });
  return next;
}

// ---- Application lifecycle: Draft -> Ready for Review -> Submitted ->
// Approved/Certified, with Reopen/Revise and Void as controlled branches. ----
function setAiaApplicationHeader(project, applicationId, fields, actingRole, actingUser) {
  const next = cloneDeep(project);
  const app = next.applications.find(a => a.id === applicationId);
  app.header = { ...app.header, ...fields };
  return next;
}
function setAiaApplicationStage(project, applicationId, status, actingRole, actingUser) {
  const next = cloneDeep(project);
  const app = next.applications.find(a => a.id === applicationId);
  app.status = status;
  next.changeLog = next.changeLog || [];
  next.changeLog.unshift({ id: uid('log'), date: todayISO(), role: actingRole, user: actingUser, action: `Application ${app.number} marked ${status}.` });
  return next;
}
function certifyAiaApplication(project, applicationId, certifiedAmount, actingRole, actingUser) {
  const next = cloneDeep(project);
  const app = next.applications.find(a => a.id === applicationId);
  app.status = 'Approved/Certified';
  app.certifiedAmount = roundMoney(certifiedAmount);
  app.certifiedBy = actingUser;
  app.certifiedDate = todayISO();
  next.changeLog = next.changeLog || [];
  next.changeLog.unshift({ id: uid('log'), date: todayISO(), role: actingRole, user: actingUser, action: `Certified Application ${app.number} for ${fmtMoney(app.certifiedAmount)}.` });
  return next;
}
// Reopening snapshots the application's current billable state to history[]
// (append-only, mirrors the Scopes & Schedule stage-history undo pattern)
// before unlocking it for editing — never a silent overwrite of certified
// numbers. Status becomes 'Revised' until it's resubmitted.
function reopenAiaApplication(project, applicationId, reason, actingRole, actingUser) {
  const next = cloneDeep(project);
  const app = next.applications.find(a => a.id === applicationId);
  app.history = app.history || [];
  app.history.push({
    date: todayISO(), user: actingUser, reason, fromStatus: app.status,
    snapshot: cloneDeep({ lines: app.lines, coLines: app.coLines, header: app.header, certifiedAmount: app.certifiedAmount }),
  });
  app.status = 'Revised';
  next.changeLog = next.changeLog || [];
  next.changeLog.unshift({ id: uid('log'), date: todayISO(), role: actingRole, user: actingUser, action: `Reopened Application ${app.number} for revision: ${reason}` });
  return next;
}
function voidAiaApplication(project, applicationId, reason, actingRole, actingUser) {
  const next = cloneDeep(project);
  const app = next.applications.find(a => a.id === applicationId);
  app.status = 'Voided';
  app.voidReason = reason;
  next.changeLog = next.changeLog || [];
  next.changeLog.unshift({ id: uid('log'), date: todayISO(), role: actingRole, user: actingUser, action: `Voided Application ${app.number}: ${reason}` });
  return next;
}

// ============================================================================
// Production (§ new request) — Project → Scope → Vendor → Production Record
// ============================================================================

// Latest entry in a revision list — the one with the highest revision number
// (falls back to array order if numbers tie/are missing).
function latestProductionDoc(list) {
  if (!list || list.length === 0) return null;
  return list.reduce((best, d) => (!best || (d.revisionNumber || 0) >= (best.revisionNumber || 0)) ? d : best, null);
}

// A production record is "Approved for Shipping" only when a QC report
// exists and the LATEST one is not a Fail and carries final approval —
// exactly the auto-flag rule requested: any open Fail blocks shipping until
// corrected and re-approved.
function productionShippingStatus(record) {
  const latestQc = latestProductionDoc(record.qcReports);
  if (!latestQc) return { approved: false, reason: 'No QC report on file yet.' };
  if (latestQc.result === 'Fail' || latestQc.finalApprovalStatus === 'Not Approved') {
    return { approved: false, reason: `QC ${latestQc.result === 'Fail' ? 'failed' : 'not approved'} on ${fmtDate(latestQc.inspectionDate)} — corrective action required before shipping.` };
  }
  if (latestQc.finalApprovalStatus === 'Approved') {
    return { approved: true, reason: `QC approved ${fmtDate(latestQc.inspectionDate)}.` };
  }
  return { approved: false, reason: 'QC result recorded but not yet finalized.' };
}

// Per-scope summary row per vendor production record, matching the requested
// columns: Vendor → Drawing Status → Production Status → QC Status →
// Latest Revision → Last Update → Approved for Shipping.
function productionRecordSummary(record) {
  const latestDrawing = latestProductionDoc(record.drawings);
  const latestQc = latestProductionDoc(record.qcReports);
  const latestPhoto = latestProductionDoc(record.photos);
  const shipping = productionShippingStatus(record);
  const allDates = [
    latestDrawing?.uploadDate, latestQc?.uploadDate, latestPhoto?.date, record.createdDate,
  ].filter(Boolean);
  const lastUpdate = allDates.length ? allDates.reduce((a, b) => (a > b ? a : b)) : record.createdDate;
  return {
    record,
    drawingStatus: latestDrawing ? `Rev ${latestDrawing.revisionNumber} — ${latestDrawing.status}` : 'None on file',
    productionStatus: record.status,
    qcStatus: latestQc ? `${latestQc.result} (${latestQc.finalApprovalStatus})` : 'Not inspected',
    latestRevision: latestDrawing ? latestDrawing.revisionNumber : null,
    lastUpdate,
    shipping,
  };
}

// ---------------------------------------------------------------------------
// "You just did the thing that stage is about — mark it complete?"
// ---------------------------------------------------------------------------
// Uploading a quote, a drawing revision or a take-off almost always means the
// stage it belongs to is finished. Almost. So the app ASKS rather than closing
// the stage on its own: a stage silently marked complete is worse than one left
// open, because nobody goes looking for a mistake they were never told about.
//
// Picking the target stage also settles the revision case for free. A family is
// the base stage plus any revision stages inserted after it
// (insertRevisionStage names them `<key>_revision_N`), and the suggestion is
// the LAST one still open — so once a revision round exists, the revision is
// what gets completed, not the original.
function stageFamilyFor(stages, stageKey) {
  return (stages || []).filter(s => s.key === stageKey || String(s.key || '').startsWith(stageKey + '_revision_'));
}
function findStageToSuggest(project, scopeId, stageKey) {
  if (!project) return null;
  const stages = scopeId
    ? ((project.scopes || []).find(s => s.id === scopeId) || {}).stages
    : project.chronology;
  const open = stageFamilyFor(stages, stageKey).filter(s => s.status !== 'Completed');
  return open.length ? open[open.length - 1] : null;
}

// ---------------------------------------------------------------------------
// Container booking lead time
// ---------------------------------------------------------------------------
// A container has to be booked BEFORE production finishes, not after — by the
// time the last crate is closed the sailing is already gone. Ten days is the
// lead the export desk works to, so the warning is raised ten days before the
// production stage is due to complete, and it keeps escalating past that date
// rather than disappearing.
const CONTAINER_BOOKING_LEAD_DAYS = 10;
function scopeBookedIntoContainer(project, scopeId, exportContainers) {
  return (exportContainers || []).some(c =>
    c.status !== 'Cancelled'
    && (c.shipments || []).some(sh => sh.projectId === project.id
        && (!sh.scopeIds || !sh.scopeIds.length || sh.scopeIds.includes(scopeId))));
}
// The date production is DUE to finish. The scope's own production stage is the
// forecast the rest of the schedule already runs on, so the warning is driven
// off the same number rather than a second estimate that could disagree.
function scopeProductionDue(scope) {
  const st = (scope.stages || []).find(x => x.key === 'production');
  if (!st) return null;
  return st.actualCompletion || st.plannedDue || null;
}
// One alert per scope that needs a container and has not got one.
function containerBookingAlerts(project, exportContainers, today) {
  const now = today || todayISO();
  const out = [];
  (project.scopes || []).forEach(scope => {
    if (scopeIsLaborOnly(scope)) return;              // nothing ships on a labour scope
    const records = (project.productionRecords || []).filter(r => r.scopeId === scope.id);
    if (!records.length) return;
    if (scopeBookedIntoContainer(project, scope.id, exportContainers)) return;
    const due = scopeProductionDue(scope);
    if (!due) return;
    const bookBy = addDays(due, -CONTAINER_BOOKING_LEAD_DAYS);
    const daysToBookBy = daysBetween(now, bookBy);
    if (daysToBookBy > 0) return;                      // not yet due to act
    const complete = records.every(r => r.status === 'Complete');
    out.push({
      scopeId: scope.id, scopeName: scope.name,
      productionDue: due, bookBy,
      daysLate: -daysToBookBy,
      daysToProduction: daysBetween(now, due),
      // Past the production date with no container is the worst case: the
      // material is finished and has nowhere to go.
      severity: complete || daysBetween(now, due) < 0 ? 'critical' : 'warning',
      productionComplete: complete,
    });
  });
  return out;
}

// A scope is ready to move into Export/Shipping once every vendor production
// record for it is Complete and cleared by QC.
function scopeReadyForExport(project, scopeId) {
  const records = (project.productionRecords || []).filter(r => r.scopeId === scopeId);
  if (records.length === 0) return false;
  return records.every(r => r.status === 'Complete' && productionShippingStatus(r).approved);
}

// ---------------------------------------------------------------------------
// Task bucketing for the Tasks tab (Today / Upcoming / Overdue / Completed)
// ---------------------------------------------------------------------------
// Widened for My To-Do (§ personal to-do request), which buckets tasks,
// personal items, and stage deadlines side by side — all three just need a
// date and a completed flag, not a task-shaped object.
function dateBucket(dateStr, isCompleted) {
  if (isCompleted) return 'Completed';
  if (!dateStr) return 'Upcoming';
  const today = todayISO();
  if (dateStr < today) return 'Overdue';
  if (dateStr === today) return 'Today';
  return 'Upcoming';
}
function taskBucket(task) { return dateBucket(task.dueDate, task.status === 'Completed'); }

// ---------------------------------------------------------------------------
// Persistence (localStorage) — lets the prototype behave statefully across reloads
// ---------------------------------------------------------------------------
const STORAGE_KEY = 'leon-ops-hub-state-v1';

function loadPersistedState() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    return JSON.parse(raw);
  } catch (e) {
    return null;
  }
}

function savePersistedState(state) {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  } catch (e) {
    // ignore quota errors in prototype
  }
}

function resetPersistedState() {
  try { localStorage.removeItem(STORAGE_KEY); } catch (e) {}
}

// ---------------------------------------------------------------------------
// Login session (localStorage) — kept separate from app state above so
// logging out never touches project/financial data. This is a prototype-only
// gate: the "password" check happens entirely in the browser, so it deters
// casual access but is not real security (see docs/production-audit).
// ---------------------------------------------------------------------------
const AUTH_STORAGE_KEY = 'leon-ops-hub-auth-v1';

function loadAuthSession() {
  try {
    const raw = localStorage.getItem(AUTH_STORAGE_KEY);
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    return parsed && parsed.userId ? parsed.userId : null;
  } catch (e) {
    return null;
  }
}

function saveAuthSession(userId) {
  try {
    localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ userId }));
  } catch (e) {
    // ignore quota errors in prototype
  }
}

// ---------------------------------------------------------------------------
// Device unlock — Face ID / Touch ID, and what it honestly is
// ---------------------------------------------------------------------------
// WebAuthn's platform authenticator is the browser's route to Face ID, Touch ID
// and Windows Hello. Used properly it is strong authentication — but only
// because a SERVER verifies the signature and holds the public key. This app
// has no server, so what is built here is a **device lock**, not authentication:
// it asks the operating system to confirm the person in front of the machine
// before the session is revealed.
//
// That is worth having — the realistic threat is a colleague or a visitor
// picking up an unlocked laptop — and it is stated in those words on the
// screen. It is NOT a cryptographic protection: anyone who can open developer
// tools can read the same localStorage the app reads. Saying otherwise would be
// the dangerous lie, so the setting says exactly this.
const BIO_STORAGE_KEY = 'leon-ops-hub-bio-v1';

function bioSupported() {
  return !!(window.PublicKeyCredential && navigator.credentials && navigator.credentials.create);
}
// Is a platform authenticator (the built-in one — Face ID / Touch ID / Hello)
// actually present? A security key plugged into USB is not what was asked for.
function bioPlatformAvailable() {
  if (!bioSupported() || !PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) {
    return Promise.resolve(false);
  }
  return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().catch(() => false);
}
function bioLoad() {
  try { return JSON.parse(localStorage.getItem(BIO_STORAGE_KEY) || 'null'); } catch (e) { return null; }
}
function bioSaveFor(userId, credentialId) {
  const all = bioLoad() || {};
  all[userId] = { credentialId, enrolledAt: new Date().toISOString() };
  try { localStorage.setItem(BIO_STORAGE_KEY, JSON.stringify(all)); } catch (e) {}
}
function bioForgetFor(userId) {
  const all = bioLoad() || {};
  delete all[userId];
  try { localStorage.setItem(BIO_STORAGE_KEY, JSON.stringify(all)); } catch (e) {}
}
function bioEnrolledFor(userId) { const all = bioLoad(); return !!(all && all[userId]); }

function bioRandom(n) {
  const a = new Uint8Array(n || 32);
  (window.crypto || {}).getRandomValues ? window.crypto.getRandomValues(a) : a.fill(7);
  return a;
}
function bioB64(buf) {
  const b = new Uint8Array(buf); let s = '';
  for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
  return btoa(s);
}
function bioFromB64(str) {
  const bin = atob(str); const a = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) a[i] = bin.charCodeAt(i);
  return a;
}

// Enrol this device for a person who has ALREADY signed in with their password.
// Enrolment is never a way IN — it is a shortcut set up from inside.
function bioEnroll(user) {
  if (!bioSupported()) return Promise.reject(new Error('This browser has no biometric support.'));
  return navigator.credentials.create({
    publicKey: {
      challenge: bioRandom(32),
      rp: { name: 'LEON Operations Hub' },
      user: { id: bioRandom(16), name: user.username || user.email || user.name, displayName: user.name },
      pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
      authenticatorSelection: { authenticatorAttachment: 'platform', userVerification: 'required', residentKey: 'preferred' },
      timeout: 60000, attestation: 'none',
    },
  }).then(cred => {
    if (!cred) throw new Error('No credential was created.');
    bioSaveFor(user.id, bioB64(cred.rawId));
    return true;
  });
}
// Ask the operating system to confirm the person. Resolves only if it does.
function bioVerify(userId) {
  const rec = (bioLoad() || {})[userId];
  if (!rec) return Promise.reject(new Error('This device is not set up for that account.'));
  return navigator.credentials.get({
    publicKey: {
      challenge: bioRandom(32),
      allowCredentials: [{ type: 'public-key', id: bioFromB64(rec.credentialId) }],
      userVerification: 'required', timeout: 60000,
    },
  }).then(a => { if (!a) throw new Error('Not confirmed.'); return true; });
}

function clearAuthSession() {
  try { localStorage.removeItem(AUTH_STORAGE_KEY); } catch (e) {}
}

// ---------------------------------------------------------------------------
// Navigation memory — which screen the user was on, so a refresh returns them
// to it instead of bouncing back to the landing page.
// ---------------------------------------------------------------------------
// sessionStorage rather than localStorage is deliberate: it survives a page
// refresh (same tab) but NOT a brand-new browser session. That gives exactly
// the two behaviours asked for — refreshing keeps your place, while signing in
// fresh always lands on My To-Do rather than dropping you deep inside whatever
// project happened to be open days earlier.
const NAV_STORAGE_KEY = 'leon-ops-hub-nav-v1';

function loadNavState() {
  try {
    const raw = sessionStorage.getItem(NAV_STORAGE_KEY);
    if (!raw) return {};
    const parsed = JSON.parse(raw);
    return parsed && typeof parsed === 'object' ? parsed : {};
  } catch (e) {
    return {};
  }
}
function saveNavState(nav) {
  try { sessionStorage.setItem(NAV_STORAGE_KEY, JSON.stringify(nav || {})); } catch (e) {}
}
function clearNavState() {
  try { sessionStorage.removeItem(NAV_STORAGE_KEY); } catch (e) {}
}

// ---------------------------------------------------------------------------
// Misc small helpers used across components
// ---------------------------------------------------------------------------
function readFileAsDataURL(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.onerror = reject;
    reader.readAsDataURL(file);
  });
}

function initials(name) {
  if (!name) return '';
  return name.split(' ').map(p => p[0]).join('').slice(0, 2).toUpperCase();
}

function personName(teamDirectory, id) {
  const p = teamDirectory.find(t => t.id === id);
  return p ? p.name : 'Unassigned';
}

function healthDot(level) {
  return HEALTH[level] ? HEALTH[level].color : '#999';
}

function fmtTime(t) {
  if (!t) return '';
  const [h, m] = t.split(':').map(Number);
  const period = h >= 12 ? 'PM' : 'AM';
  const h12 = h % 12 || 12;
  return `${h12}:${String(m).padStart(2, '0')} ${period}`;
}

function addMinutesToTime(t, minutes) {
  const [h, m] = t.split(':').map(Number);
  const total = (h * 60 + m + minutes + 1440) % 1440;
  return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
}

function durationLabel(minutes) {
  if (!minutes) return '';
  const h = Math.floor(minutes / 60), m = minutes % 60;
  if (!h) return `${m} min`;
  if (!m) return `${h} hr${h > 1 ? 's' : ''}`;
  return `${h} hr${h > 1 ? 's' : ''} ${m} min`;
}

// Start–end range when a duration is set ("8:00 AM – 8:30 AM"), otherwise
// just the start time ("8:00 AM").
function fmtTimeRange(t, durationMinutes) {
  if (!t) return '';
  if (!durationMinutes) return fmtTime(t);
  return `${fmtTime(t)} – ${fmtTime(addMinutesToTime(t, durationMinutes))}`;
}

// ══════════════════════════════════════════════════ Quote Analysis (Interiors)
// Reads a LEON Take-Off Template workbook into priced-quote sections.
//
// Two decisions worth knowing:
//
// 1. Quantities are RECOMPUTED here, never read from the sheet's cached
//    formula results. A template that has been generated but never opened in
//    Excel carries formulas with no cached value at all, so trusting the cache
//    would silently import a workbook of zeroes. Recomputing also means the
//    arithmetic behind an imported number is the same arithmetic the app shows.
// 2. Columns are matched by HEADER NAME, not position, so a workbook saved from
//    an older version of the template (before Category was inserted) still
//    imports correctly instead of shifting every field one to the left.
const TAKEOFF_SKIP_SHEETS = ['how to use', 'project overview', 'unit matrix',
  'scope summary', 'category summary', 'validation', '_lists', 'lists'];

const TAKEOFF_FIELD_ALIASES = {
  area: ['area'], category: ['category'], location: ['location / room', 'location', 'room'],
  itemTag: ['item tag', 'tag', 'item no', 'item no.'], description: ['description'],
  unitType: ['unit type'], qtyPerUnit: ['qty per unit', 'qty/unit', 'quantity per unit'],
  unitQty: ['unit qty', 'units'], subtotal: ['subtotal'], wastePct: ['waste %', 'waste%', 'waste'],
  wasteQty: ['waste qty'], totalQty: ['total qty', 'total quantity', 'total'],
  uom: ['uom', 'unit of measure', 'u.o.m.'], drawingRef: ['drawing ref', 'drawing reference', 'dwg ref'],
  measuredBy: ['measured by'], date: ['date'], status: ['status'], notes: ['notes', 'note'],
};

function titleCaseScope(v) {
  return String(v).toLowerCase().replace(/\b([a-z])/g, (m, c) => c.toUpperCase())
    .replace(/\bLvt\b/g, 'LVT').replace(/\bSpc\b/g, 'SPC').replace(/\bUom\b/g, 'UoM');
}

function takeoffNorm(v) { return String(v == null ? '' : v).trim().toLowerCase().replace(/\s+/g, ' '); }
function takeoffNum(v) {
  if (v === null || v === undefined || v === '') return null;
  if (typeof v === 'number') return isFinite(v) ? v : null;
  const n = parseFloat(String(v).replace(/[^0-9.\-]/g, ''));
  return isFinite(n) ? n : null;
}

// The Unit Matrix, resolved locally. Its Total Qty is itself a formula, so the
// per-level counts are summed here rather than read back.
function parseTakeoffUnitMatrix(rows) {
  const map = {};
  if (!rows) return map;
  let hdr = -1;
  rows.forEach((r, i) => {
    if (hdr === -1 && (r || []).some(c => takeoffNorm(c) === 'unit type name')) hdr = i;
  });
  if (hdr === -1) return map;
  const head = rows[hdr].map(takeoffNorm);
  const nameCol = head.indexOf('unit type name');
  const totalCol = head.indexOf('total qty');
  const levelCols = head.map((h, i) => (/^l\d+$/.test(h) ? i : -1)).filter(i => i >= 0);
  for (let i = hdr + 1; i < rows.length; i++) {
    const row = rows[i] || [];
    const name = String(row[nameCol] == null ? '' : row[nameCol]).trim();
    if (!name || takeoffNorm(name) === 'total units') continue;
    let total = levelCols.reduce((a, c) => a + (takeoffNum(row[c]) || 0), 0);
    if (!total) total = takeoffNum(row[totalCol]) || 0;
    map[takeoffNorm(name)] = total;
  }
  return map;
}

// The tab's own waste allowance — the "Waste allowance" label with its value
// two cells to the right, which is where every line's Waste % formula points.
function parseTakeoffSheetWaste(rows) {
  for (let i = 0; i < Math.min(rows.length, 12); i++) {
    const row = rows[i] || [];
    for (let c = 0; c < row.length; c++) {
      if (takeoffNorm(row[c]).startsWith('waste allowance')) {
        for (let k = c + 1; k <= c + 4; k++) {
          const n = takeoffNum(row[k]);
          if (n !== null) return n;
        }
      }
    }
  }
  return null;
}

// UoM on the template is a formula holding a constant string per tab
// (=IF(tag="","","Sq. Ft.")). A workbook that has never been opened in Excel
// carries no cached value for it, and SheetJS drops a formula-only cell
// entirely — so the unit is taken from TAKEOFF_SCOPES, the same scope contract
// the template itself was generated from. Importing a quantity with no unit is
// exactly how square feet and linear feet get swapped.
function takeoffUomForSheet(sheetName) {
  const hit = (typeof TAKEOFF_SCOPES !== 'undefined')
    ? TAKEOFF_SCOPES.find(x => takeoffNorm(x.tab) === takeoffNorm(sheetName))
    : null;
  if (hit) return hit.uom;
  const guess = (typeof takeoffScopeFor === 'function') ? takeoffScopeFor(sheetName, '') : null;
  return guess ? guess.uom : '';
}

function parseTakeoffWorkbook(arrayBuffer) {
  if (!window.XLSX) throw new Error('Excel library did not load — reload the page and try again.');
  const wb = XLSX.read(arrayBuffer, { type: 'array' });
  const grid = name => XLSX.utils.sheet_to_json(wb.Sheets[name], { header: 1, defval: '', blankrows: true });

  const umSheet = wb.SheetNames.find(n => takeoffNorm(n).includes('unit matrix'));
  const units = umSheet ? parseTakeoffUnitMatrix(grid(umSheet)) : {};

  const sections = [];
  const warnings = [];

  wb.SheetNames.forEach(name => {
    const n = takeoffNorm(name).replace(/^\d+\.\s*/, '');
    if (TAKEOFF_SKIP_SHEETS.some(s => n.includes(s))) return;

    const rows = grid(name);
    // Anchor on the header row rather than a fixed row number, so a workbook
    // where someone inserted a row above the table still reads correctly.
    let hdr = -1;
    for (let i = 0; i < Math.min(rows.length, 30); i++) {
      const r = (rows[i] || []).map(takeoffNorm);
      if (r.includes('item tag') && r.includes('description')) { hdr = i; break; }
    }
    if (hdr === -1) { warnings.push(`"${name}" has no take-off table — skipped.`); return; }

    const head = (rows[hdr] || []).map(takeoffNorm);
    const col = {};
    Object.keys(TAKEOFF_FIELD_ALIASES).forEach(f => {
      col[f] = head.findIndex(h => h && TAKEOFF_FIELD_ALIASES[f].includes(h));
    });
    // Whatever sits between Description and Unit Type is that trade's own
    // specification block. It is kept under its real column names so a
    // countertop line still says "Edge Profile" once it is in the quote.
    const specCols = [];
    if (col.description >= 0 && col.unitType > col.description) {
      for (let c = col.description + 1; c < col.unitType; c++) {
        if (head[c]) specCols.push({ c, label: (rows[hdr] || [])[c] });
      }
    }

    const sheetWaste = parseTakeoffSheetWaste(rows);
    const fallbackUom = takeoffUomForSheet(name);
    const lines = [];
    for (let i = hdr + 1; i < rows.length; i++) {
      const row = rows[i] || [];
      const get = f => (col[f] >= 0 ? row[col[f]] : '');
      const tag = String(get('itemTag') || '').trim();
      const desc = String(get('description') || '').trim();
      if (!tag && !desc) continue;

      const unitType = String(get('unitType') || '').trim();
      let unitQty = 1;
      if (unitType) {
        const hit = units[takeoffNorm(unitType)];
        if (hit === undefined) {
          warnings.push(`"${name}" line ${tag || desc}: unit type "${unitType}" is not on the Unit Matrix — counted as 1.`);
        } else unitQty = hit;
      }
      const qtyPerUnit = takeoffNum(get('qtyPerUnit'));
      let wastePct = takeoffNum(get('wastePct'));
      if (wastePct === null) wastePct = sheetWaste === null ? 0 : sheetWaste;
      if (wastePct > 1) wastePct = wastePct / 100;   // someone typed 15 for 15%

      const subtotal = (qtyPerUnit || 0) * unitQty;
      const qty = subtotal * (1 + wastePct);
      const specs = {};
      specCols.forEach(s => {
        const v = String(row[s.c] == null ? '' : row[s.c]).trim();
        if (v) specs[s.label] = v;
      });
      const status = String(get('status') || '').trim();

      lines.push(makeQuoteLine({
        area: String(get('area') || '').trim(),
        category: String(get('category') || '').trim(),
        location: String(get('location') || '').trim(),
        itemTag: tag, description: desc, specs,
        unitType, qtyPerUnit, unitQty, wastePct,
        qty: Math.round(qty * 10000) / 10000,
        uom: String(get('uom') || '').trim() || fallbackUom,
        drawingRef: String(get('drawingRef') || '').trim(),
        takeoffStatus: status,
        takeoffNote: String(get('notes') || '').trim(),
        // A line the take-off itself did not settle is carried in but not
        // priced, so it cannot quietly become part of a number sent to a client.
        excluded: /not in scope|by others/i.test(status),
      }));
      if (/to verify/i.test(status)) {
        warnings.push(`"${name}" line ${tag || desc} is still marked To Verify.`);
      }
    }

    if (!lines.length) return;
    const uoms = [...new Set(lines.map(l => l.uom).filter(Boolean))];
    // The tab's own title sits in the branded header block; fall back to the
    // sheet name rather than leaving a scope unnamed.
    const title = String((rows[hdr - 6] || [])[4] || '').trim();
    sections.push(makeQuoteSection({
      name: title ? titleCaseScope(title) : name,
      scopeKey: name, uom: uoms.length === 1 ? uoms[0] : (uoms[0] || fallbackUom), lines,
    }));
  });

  if (!sections.length && !warnings.length) warnings.push('No take-off tables were found in this workbook.');
  return { sections: splitQuoteSupplyLabor(sections), warnings, units };
}

// ── The money ────────────────────────────────────────────────────────────
// Two ways to price, chosen per SCOPE and overridable per ITEM, because both
// are real. A casework package is usually priced off cost plus a target
// margin; a flooring scope is often just a known $/sq ft the client is quoted
// and the margin is whatever falls out. The screen always shows the resulting
// margin either way, so the number is never implied.
//
// Resolution is always line -> section -> analysis. A value left null follows
// the level above; 0 is a real answer meaning "none", and the two must never
// collapse together — that distinction is why these are nulls and not zeroes.
function qnum(v) {
  if (v === null || v === undefined || v === '') return 0;
  const n = typeof v === 'number' ? v : parseFloat(String(v).replace(/[^0-9.\-]/g, ''));
  return isFinite(n) ? n : 0;
}
function qpick(...vals) {
  for (const v of vals) if (v !== null && v !== undefined && v !== '') return v;
  return null;
}

const QUOTE_PRICING_METHODS = [
  { key: 'rate', label: 'Cost + margin', hint: 'Price is built up from cost and a target percentage.' },
  { key: 'unitPrice', label: 'Set unit price', hint: 'Price is a rate per unit; the margin is whatever it earns.' },
  // Sell + margin: you name the SELLING price and the margin is whatever that
  // earns over the cost the recipe works out. It is the same arithmetic as
  // `sellOverride` on a line, offered at scope level — quoting to a number the
  // client has already been given, and finding out afterwards what it earns.
  { key: 'sell', label: 'Sell + margin', hint: 'You set the sell price; the margin is whatever it earns over cost.' },
];

function quoteMethodFor(line, section, qa) {
  return qpick(line && line.pricingMethod, section && section.pricingMethod, qa && qa.pricingMethod === 'unitPrice' ? 'unitPrice' : null) || 'rate';
}

// Freight and duty are charged against MATERIAL, never labour — labour is
// neither shipped nor dutiable. What varies is the basis: a slab is charged by
// the volume it occupies in a container, a door by the piece, an amenity
// package by one negotiated figure, and duty follows each item's own tariff
// code. Resolution is line -> scope -> analysis, same as every other rate.
// What this line ships, in the unit its container is measured in. Casework
// counts modules, base counts sticks of trim, everything else counts what the
// take-off measured. The driver is what the container fraction divides.
function quoteDriverQty(line, sec, qty) {
  const driver = line.costDriver || sec.costDriver || 'qty';
  // Modules in ONE item times how many of that item — 18 modules in a kitchen
  // across 36 kitchens is 648 modules, and 648 is what fills containers. The
  // per-item figure alone would size the shipment for a single apartment.
  if (driver === 'module') return quoteLineQty(line);
  if (driver === 'piece') {
    // Linear feet into sticks: 12 inches to the foot, divided by the stick.
    const len = qnum(qpick(line.pieceLengthIn, sec.pieceLengthIn)) || 96;
    return len ? (qty * 12) / len : 0;
  }
  return qty;
}
// Material, which is bought one of two ways: at a rate per unit, or by the slab.
// The slab route is stone's: square feet plus waste, divided by what one slab
// actually yields, times the slab price. Returns the parts, because a quote that
// cannot show why the stone costs what it does is not auditable.
function quoteMaterialFor(line, sec, qa, qty) {
  const basis = qpick(line.matBasis, sec.matBasis) || 'unit';
  if (basis === 'slab') {
    const waste = qnum(qpick(line.slabWastePct, sec.slabWastePct));
    const yieldPer = qnum(qpick(line.slabYield, sec.slabYield));
    const rate = qnum(qpick(line.slabRate, sec.slabRate));
    const slabs = yieldPer ? (qty * (1 + waste)) / yieldPer : 0;
    // A cut-to-size rate can be charged on top of the slab, as the workbook does.
    const c2s = qty * qnum(line.matUnit);
    return { mat: slabs * rate + c2s, slabs, slabRate: rate, c2s, basis };
  }
  return { mat: qty * qnum(line.matUnit), slabs: 0, slabRate: 0, c2s: 0, basis };
}
// Freight by the container: a FRACTION of a container, and three separate legs
// charged against it. Returns the breakdown so the line can show its working.
function quoteContainerFreight(line, sec, qa, driverQty) {
  const v = f => qpick(line[f], sec[f], qa[f]);
  const capacity = qnum(v('containerCapacity'));
  const containers = capacity ? driverQty / capacity : 0;
  const ocean = containers * qnum(v('freightPerContainer'));
  const inland = containers * qnum(v('inlandPerContainer'));
  const broker = containers * qnum(v('brokerPerContainer'));
  return { containers, ocean, inland, broker, total: ocean + inland + broker };
}
function quoteFreightFor(line, sec, qa, qty, mat) {
  const basis = qpick(line.freightBasis, sec.freightBasis, qa.freightBasis) || 'pct';
  const v = f => qpick(line[f], sec[f], qa[f]);
  if (basis === 'container') return quoteContainerFreight(line, sec, qa, quoteDriverQty(line, sec, qty)).total;
  if (basis === 'perUnit') return qty * qnum(v('freightPerUnit'));
  if (basis === 'lump') return qnum(qpick(line.freightLump, sec.freightLump));
  if (basis === 'volume') {
    // CBM is a property of the item, so it is only ever read off the line;
    // the rate per cubic metre is the shipment's and can be inherited.
    return qty * qnum(line.cbmPerUnit) * qnum(v('freightPerCbm'));
  }
  return mat * qnum(v('freightPct'));
}
function quoteDutyFor(line, sec, qa, qty, mat) {
  const basis = qpick(line.dutyBasis, sec.dutyBasis, qa.dutyBasis) || 'pct';
  const v = f => qpick(line[f], sec[f], qa[f]);
  if (basis === 'perUnit') return qty * qnum(v('dutyPerUnit'));
  if (basis === 'lump') return qnum(qpick(line.dutyLump, sec.dutyLump));
  return mat * qnum(v('dutyPct'));
}

// Which basis a line's freight actually resolves to, through the same
// line -> scope -> analysis inheritance as the rate itself. The row needs this
// to know whether the freight RATE cell is a number someone types or one the
// engine derived.
// Margin on the SELL price is LEON's standard, and it is what an unset scope
// inherits. A scope or a single line may still be quoted as a markup on cost —
// null means follow, exactly as it does for every other figure on a quotation.
// The difference is real money: 35% markup earns 25.9%, 35% margin earns 35%.
function quoteRateBasisFor(line, sec, qa) {
  return qpick(line && line.rateBasis, sec && sec.rateBasis, qa && qa.rateBasis) || 'margin';
}

function quoteFreightBasisFor(line, sec, qa) {
  return qpick((line || {}).freightBasis, (sec || {}).freightBasis, (qa || {}).freightBasis) || 'pct';
}

// What a line's freight/duty setting reads as, for the row that shows it.
function quoteChargeLabel(line, sec, qa, which) {
  const basis = qpick(line[`${which}Basis`], sec[`${which}Basis`], qa[`${which}Basis`]) || 'pct';
  const v = f => qpick(line[f], sec[f], qa[f]);
  const own = line[`${which}Basis`] ? '' : ' (scope)';
  if (basis === 'perUnit') return `$${qnum(v(`${which}PerUnit`))}/unit${own}`;
  if (basis === 'lump') return `$${qnum(qpick(line[`${which}Lump`], sec[`${which}Lump`])).toLocaleString()} lump${own}`;
  if (basis === 'volume') return `${qnum(line.cbmPerUnit)} CBM @ $${qnum(v('freightPerCbm'))}${own}`;
  return `${Math.round(qnum(v(`${which}Pct`)) * 1000) / 10}%${own}`;
}

// What a line actually measures. Two quantities multiplied — so many of the
// counted thing in one item, times how many identical items — falling back to
// the single `qty` for a line that never carried the split (an imported
// take-off row, or anything created before the model had both).
function quoteLineQty(line) {
  const per = qnum(line.qtyPerItem);
  const n = qnum(line.itemCount);
  if (per && n) return per * n;
  if (per) return per;
  return qnum(line.qty);
}
function quoteLineTotals(line, qa, section) {
  const sec = section || {};
  const qty = quoteLineQty(line);
  // An area heading or a note is not priced. Same zero shape as an excluded
  // line, so every caller that already handles one handles these.
  if (line.excluded || !quoteRowIsItem(line)) {
    return { qty, driverQty: 0, mat: 0, labor: 0, install: 0, overhead: 0, freight: 0, duty: 0,
             cost: 0, sell: 0, margin: 0, slabs: 0, c2s: 0, containers: null,
             markup: 0, commission: 0, bonus: 0, referral: 0, profit: 0, cm: 0, ta: 0,
             marginPct: 0, unitSell: 0, unitCost: 0,
             method: quoteRowIsItem(line) ? 'excluded' : line.rowKind,
             excluded: true, rowKind: line.rowKind || 'item' };
  }
  const m = quoteMaterialFor(line, sec, qa, qty);
  const mat = m.mat;
  const labor = qty * qnum(line.laborUnit);
  const install = qty * qnum(line.installUnit);
  // Overhead is charged on material, before freight — the order the workbook
  // uses, and it matters: charging it on the freighted cost gives a different
  // number.
  const overhead = mat * qnum(qpick(line.overheadPct, sec.overheadPct, qa.overheadPct));
  const driverQty = quoteDriverQty(line, sec, qty);
  const containers = (qpick(line.freightBasis, sec.freightBasis, qa.freightBasis) === 'container')
    ? quoteContainerFreight(line, sec, qa, driverQty) : null;
  const freight = quoteFreightFor(line, sec, qa, qty, mat);
  const duty = quoteDutyFor(line, sec, qa, qty, mat);
  const cost = mat + labor + install + overhead + freight + duty;

  const method = quoteMethodFor(line, sec, qa);
  const rate = qnum(qpick(line.ratePct, sec.ratePct, qa.defaultRatePct));
  const unitPrice = qpick(line.unitPrice, sec.unitPrice);

  let sell;
  if (line.sellOverride !== null && line.sellOverride !== undefined && line.sellOverride !== '') {
    sell = qnum(line.sellOverride);
  } else if (method === 'unitPrice') {
    sell = qty * qnum(unitPrice);
  } else if (method === 'sell') {
    // The scope was quoted to a figure someone has already given the client.
    // A line may still name its own; where neither does there is nothing to
    // price against, and the line reports itself unpriced rather than falling
    // back to a margin nobody asked for.
    sell = qnum(qpick(line.sellPrice, sec.sellPrice));
  } else if (quoteRateBasisFor(line, sec, qa) === 'markup') {
    sell = cost * (1 + rate);
  } else {
    // A 100% margin has no finite sell price; hold at cost rather than put
    // Infinity in front of a client.
    sell = rate >= 1 ? cost : cost / (1 - rate);
  }
  const margin = sell - cost;
  // What the sale costs to make, on top of making the thing. All three are
  // charged against the CLIENT PRICE, not against cost — that is how the
  // workbook does it, and it is the convention commission is normally written
  // on. Profit is what survives all of them.
  const markup = qnum(line.markupAmt);
  const commission = sell * qnum(qpick(line.commissionPct, sec.commissionPct, qa.commissionPct));
  const bonus = sell * qnum(qpick(line.bonusPct, sec.bonusPct, qa.bonusPct));
  // A referral fee is owed onward to whoever brought the job in. It is charged
  // on the client price like the other two, and it comes out of PROFIT — the
  // client is not billed more because we were introduced to them.
  // A referral is a share of the price or a flat sum agreed for the
  // introduction. The two resolve DIFFERENTLY on purpose: a percentage
  // inherits job -> scope -> line like every other rate, but a flat SUM is
  // charged once at the level it was agreed and is never inherited downward —
  // pushing $5,000 down to each of forty lines would charge it forty times.
  // So a line contributes an amount only if the LINE itself carries one; a
  // scope's or a job's is added once, in the totals below.
  const referralBasis = qpick(line.referralBasis, sec.referralBasis, qa.referralBasis) || 'pct';
  const referral = referralBasis === 'amount'
    ? qnum(line.referralAmt)
    : sell * qnum(qpick(line.referralPct, sec.referralPct, qa.referralPct));
  const profit = sell - cost - markup - commission - bonus - referral;
  return { qty, driverQty, mat, labor, install, overhead, freight, duty, cost, sell, margin,
           slabs: m.slabs, c2s: m.c2s, containers,
           markup, commission, bonus, referral, profit,
           // CM is the return on what the client pays; TA the return on what we
           // spent. The workbook shows both because they answer different
           // questions, and a scope can look fine on one and thin on the other.
           cm: sell ? profit / sell : 0, ta: cost ? profit / cost : 0,
           marginPct: sell ? margin / sell : 0,
           unitSell: qty ? sell / qty : 0, unitCost: qty ? cost / qty : 0,
           method, excluded: false };
}

function quoteSectionTotals(section, qa) {
  const acc = { mat: 0, labor: 0, install: 0, overhead: 0, freight: 0, duty: 0, cost: 0, sell: 0,
                margin: 0, markup: 0, commission: 0, bonus: 0, referral: 0, profit: 0,
                qty: 0, driverQty: 0, slabs: 0, lines: 0, excluded: 0, unpriced: 0 };
  (section.lines || []).forEach(l => {
    // A heading or a note is not a line that was left out — it was never a
    // line. Counting one as excluded would report a scope as having lines it
    // does not price.
    if (!quoteRowIsItem(l)) return;
    const t = quoteLineTotals(l, qa, section);
    if (t.excluded) { acc.excluded++; return; }
    acc.lines++; acc.qty += t.qty;
    if (!t.sell) acc.unpriced++;
    ['mat', 'labor', 'install', 'overhead', 'freight', 'duty', 'cost', 'sell', 'margin',
     'markup', 'commission', 'bonus', 'referral', 'profit', 'driverQty', 'slabs'].forEach(k => { acc[k] += t[k]; });
  });
  // A flat referral agreed on this SCOPE is charged once here, not per line.
  if ((qpick(section.referralBasis, qa && qa.referralBasis) || 'pct') === 'amount' && qnum(section.referralAmt)) {
    acc.referral += qnum(section.referralAmt);
    acc.profit -= qnum(section.referralAmt);
  }
  acc.marginPct = acc.sell ? acc.margin / acc.sell : 0;
  // The same margin stated the two ways it gets quoted, plus the unit price —
  // a scope is priced by ONE of them and read by all three, and doing that
  // conversion in your head is how 35% markup gets sold as 35% margin.
  //   margin on PRICE (on sell) = margin / sell   — the house convention
  //   margin on COST  (markup)  = margin / cost
  acc.marginOnCostPct = acc.cost ? acc.margin / acc.cost : 0;
  acc.unitSell = acc.qty ? acc.sell / acc.qty : 0;
  acc.unitCost = acc.qty ? acc.cost / acc.qty : 0;
  // Freight by the container is a CHAIN, not a rate, and the scope is where it
  // reads: what the scope carries in total, how many fill one box, and the
  // fraction that therefore ships. Null when the scope charges freight some
  // other way, so a caller can tell "not applicable" from "zero containers".
  if (quoteFreightBasisFor(null, section, qa) === 'container') {
    const cap = qnum(qpick(section.containerCapacity, qa.containerCapacity));
    acc.containerCapacity = cap;
    acc.containers = cap ? acc.driverQty / cap : 0;
  } else {
    acc.containerCapacity = 0;
    acc.containers = null;
  }
  acc.cm = acc.sell ? acc.profit / acc.sell : 0;
  acc.ta = acc.cost ? acc.profit / acc.cost : 0;
  return acc;
}

function quoteAnalysisTotals(qa) {
  const acc = { mat: 0, labor: 0, install: 0, overhead: 0, freight: 0, duty: 0, cost: 0, sell: 0,
                margin: 0, markup: 0, commission: 0, bonus: 0, referral: 0, profit: 0,
                lines: 0, excluded: 0, unpriced: 0, sections: 0 };
  (qa.sections || []).forEach(s => {
    const t = quoteSectionTotals(s, qa);
    acc.sections++;
    ['mat', 'labor', 'install', 'overhead', 'freight', 'duty', 'cost', 'sell', 'margin',
     'markup', 'commission', 'bonus', 'referral', 'profit', 'lines', 'excluded', 'unpriced']
      .forEach(k => { acc[k] += t[k]; });
  });
  acc.marginPct = acc.sell ? acc.margin / acc.sell : 0;
  acc.cm = acc.sell ? acc.profit / acc.sell : 0;
  // A flat referral agreed on the JOB is charged once across the whole
  // analysis. A scope that names its own has already been counted above, so
  // this only fires where the job carries the figure and no scope overrode it.
  // It must come BEFORE cm and ta, which are ratios OF profit.
  if ((qa.referralBasis || 'pct') === 'amount' && qnum(qa.referralAmt)
      && !(qa.sections || []).some(s => s.referralBasis === 'amount' && qnum(s.referralAmt))) {
    acc.referral += qnum(qa.referralAmt);
    acc.profit -= qnum(qa.referralAmt);
  }
  acc.cm = acc.sell ? acc.profit / acc.sell : 0;
  acc.ta = acc.cost ? acc.profit / acc.cost : 0;
  return acc;
}

// One take-off tab becomes TWO scopes in the quote — supply and labour — because
// that is how they are contracted and billed. Countertops stay as one. The two
// halves share the same quantities: the take-off measured the work once, and
// re-measuring it per contract is how the two drift apart.
// The same split, but the CALLER decides per scope: 'supply' | 'labor' |
// 'both' | 'combined' | 'skip'. splitQuoteSupplyLabor is the automatic version
// and stays for the paths that do not ask.
function buildQuoteSections(sections, choices) {
  const out = [];
  sections.forEach(sec => {
    const want = (choices && choices[sec.scopeKey || sec.name]) || 'both';
    if (want === 'skip') return;
    const combined = quoteScopeIsCombined(sec.name) || quoteScopeIsCombined(sec.scopeKey);
    if (want === 'combined' || (combined && want === 'both')) {
      out.push(makeQuoteSection({ ...sec, id: undefined, kind: 'combined',
        name: `${sec.name} — Supply & Install`,
        lines: sec.lines.map(l => makeQuoteLine({ ...l, id: undefined })) }));
      return;
    }
    if (want === 'supply' || want === 'both') {
      out.push(makeQuoteSection({ ...sec, id: undefined, kind: 'supply', name: `${sec.name} — Supply`,
        lines: sec.lines.map(l => makeQuoteLine({ ...l, id: undefined })) }));
    }
    if (want === 'labor' || want === 'both') {
      out.push(makeQuoteSection({ ...sec, id: undefined, kind: 'labor', name: `${sec.name} — Labor`,
        // Labour carries no freight and no duty: nothing is shipped and nothing
        // clears customs, so inheriting the analysis default would be wrong.
        freightBasis: 'pct', freightPct: 0, dutyBasis: 'pct', dutyPct: 0,
        lines: sec.lines.map(l => makeQuoteLine({ ...l, id: undefined })) }));
    }
  });
  return out;
}
function splitQuoteSupplyLabor(sections) {
  const out = [];
  sections.forEach(sec => {
    if (quoteScopeIsCombined(sec.name) || quoteScopeIsCombined(sec.scopeKey)) {
      out.push(makeQuoteSection({ ...sec, kind: 'combined', name: `${sec.name} — Supply & Install` }));
      return;
    }
    out.push(makeQuoteSection({
      ...sec, id: undefined, kind: 'supply', name: `${sec.name} — Supply`,
      lines: sec.lines.map(l => makeQuoteLine({ ...l, id: undefined })),
    }));
    out.push(makeQuoteSection({
      ...sec, id: undefined, kind: 'labor', name: `${sec.name} — Labor`,
      // Labour carries no freight and no duty: nothing is shipped and nothing
      // clears customs, so inheriting the analysis default would be wrong.
      freightBasis: 'pct', freightPct: 0, dutyBasis: 'pct', dutyPct: 0,
      lines: sec.lines.map(l => makeQuoteLine({ ...l, id: undefined })),
    }));
  });
  return out;
}

// One empty section per scope-library FAMILY — what the company sells, not what
// the job already has. At quote time the job has no scopes at all: they are
// created from the quotation when it is converted to a contract, which is the
// whole point of keeping the two apart. Seeding from `project.scopes` gave an
// empty list on exactly the jobs this is for.
function quoteSectionsFromFamilies(families) {
  return (families || []).map(f => makeQuoteSection({
    name: f.name, scopeKey: f.name, uom: '', lines: [],
  }));
}

// Seeded from the job's own scopes — only useful once a job HAS scopes, e.g.
// re-quoting work already contracted. Kept for that case.
function quoteSectionsFromScopes(scopes) {
  return (scopes || []).map(s => makeQuoteSection({
    name: s.name, scopeKey: s.familyName || s.name, scopeId: s.id, uom: s.unit || '',
    lines: s.quantity ? [makeQuoteLine({
      // qnum, not num: `num` is a local helper inside App() and has never been
      // reachable from lib.jsx — this threw the moment the blank-draft path was
      // actually exercised.
      description: s.name, itemTag: '', qty: qnum(s.quantity) || 0, uom: s.unit || '',
      takeoffNote: 'Seeded from the scope quantity — replace with measured lines.',
    })] : [],
  }));
}

// ── Actually sending an email ─────────────────────────────────────────────
// The Hub has no mail server and cannot have one: an API key cannot live in a
// page anyone can view source on. `emailOutbox` is therefore a queue, and it
// says so. But a browser CAN hand a fully composed message to whatever mail
// client the person already uses, and that genuinely sends — from their own
// address, through their own account, landing in their own Sent folder.
//
// The trade is format: a mailto: draft is plain text, so the branded HTML is
// the queued copy and this is the one that goes out. For chasing a quote that
// is the right trade — the point is the message arriving, not the letterhead.
const MAILTO_SAFE_CHARS = 1800;   // beyond this some clients silently truncate

function buildMailtoUrl({ to, cc, subject, body }) {
  const q = [];
  if (cc) q.push('cc=' + encodeURIComponent(cc));
  if (subject) q.push('subject=' + encodeURIComponent(subject));
  if (body) q.push('body=' + encodeURIComponent(body));
  return 'mailto:' + encodeURIComponent(to || '') + (q.length ? '?' + q.join('&') : '');
}

// The plain-text body a mail client gets. Deliberately shorter than the queued
// HTML version — a draft that arrives truncated is worse than a brief one.
function buildFollowUpText({ recipientName, senderName, senderTitle, senderSignature,
                             company, projectName, revLabel, revAmount, revDate, message }) {
  const L = [];
  L.push(`Hi ${(recipientName || '').split(' ')[0] || 'there'},`);
  L.push('');
  L.push(message && message.trim() ? message.trim()
    : `Just following up on our quotation for ${projectName}${revLabel ? ` (${revLabel})` : ''}.`);
  L.push('');
  if (revLabel) {
    L.push(`  Project:   ${projectName}`);
    L.push(`  Quotation: ${revLabel}${revAmount ? ` — ${revAmount}` : ''}${revDate ? `, issued ${revDate}` : ''}`);
    L.push('');
  }
  L.push('Happy to walk through it or adjust anything that needs revisiting.');
  L.push('');
  L.push('Best regards,');
  if (senderSignature && senderSignature.trim()) {
    L.push(senderSignature.trim());
  } else {
    L.push(senderName || '');
    if (senderTitle) L.push(senderTitle);
    const co = company || {};
    if (co.tradeName || co.name) L.push(co.tradeName || co.name);
    const addr = [co.addressLine1, co.addressLine2].filter(Boolean).join(', ');
    if (addr) L.push(addr);
    if (co.phone) L.push(co.phone);
    if (co.website) L.push(co.website);
  }
  return L.join('\n');
}


// ── Weekly quotation chasing ──────────────────────────────────────────────
// A quotation that is not chased is a quotation that goes quiet, so this keeps
// a weekly cadence running against every open quote until it converts or dies.
//
// The queue is COMPUTED on every render from the quote revisions and the
// follow-up history — never stored. That is the same choice `buildDueAlerts`
// makes and for the same reason: this app has no server and nothing to fire a
// scheduler, so a stored "next due" row would quietly rot. Recomputing means
// the answer is always right the moment someone opens the screen.
//
// What it cannot do is send by itself. A browser with no backend cannot, and an
// API key cannot live in a page. So it does the honest version: it works out
// WHO is due, WHAT to say this week, and hands each one to the mail client
// ready to go. The person presses send.
const QUOTE_CHASE_INTERVAL_DAYS = 7;

// One message per round, escalating. Repeating the same note every week is how
// a follow-up becomes noise, so each round has a different job to do — and the
// last one stops asking rather than nagging forever.
const QUOTE_CHASE_SEQUENCE = [
  { round: 1, tone: 'Check it landed',
    subject: 'Quotation for {project}',
    body: 'Just making sure our quotation for {project} reached you — I know things move quickly.\n\nHappy to answer anything on it whenever suits.' },
  { round: 2, tone: 'Offer to walk through it',
    subject: 'Happy to walk you through the {project} quotation',
    body: 'Following up on {revLabel} for {project}.\n\nIf it would help, I am glad to walk through the scopes and pricing so you can see exactly what is included — usually quicker than reading it cold.' },
  { round: 3, tone: 'Ask what needs changing',
    subject: 'Anything you would like adjusted on {project}?',
    body: 'Checking back on {revLabel} for {project}.\n\nIf anything needs to move — scope, specification or budget — tell me what you need it to look like and I will rework it. It is easier to adjust now than after award.' },
  { round: 4, tone: 'Flag lead times',
    subject: '{project} — lead times worth knowing about',
    body: 'Still holding {revLabel} for {project}.\n\nWorth flagging that our production and shipping windows move with demand. If the schedule matters on this one, an early decision protects the dates — I can reserve capacity while you finalise.' },
  { round: 5, tone: 'Ask directly',
    subject: '{project} — where are we?',
    body: 'Following up again on {revLabel} for {project}.\n\nCould you let me know where this sits on your side? If it is going ahead I will get ready; if it has gone another way, I would rather know so I can stop chasing you.' },
  { round: 6, tone: 'Close the loop',
    subject: '{project} — closing the file unless I hear otherwise',
    body: 'Last note on {revLabel} for {project}.\n\nI have not heard back, so I will assume this one is not moving and close the file. Nothing lost — reopen it any time and I will pick it straight back up.\n\nThanks for the opportunity either way.' },
];
function quoteChaseStep(round) {
  return QUOTE_CHASE_SEQUENCE[Math.min(Math.max(round, 1), QUOTE_CHASE_SEQUENCE.length) - 1];
}

// Only a quote that is still live gets chased. Once it converts to a job, is
// lost, or is finished, the cadence stops — that is what "until we convert it"
// means, and it has to be automatic or someone will keep chasing a won job.
const QUOTE_CHASE_OPEN_STATUSES = ['Lead', 'Active Quotation'];

function buildQuoteChaseQueue(projects, accounts, today) {
  const t = today || todayISO();
  const out = [];
  (projects || []).forEach(p => {
    if (!QUOTE_CHASE_OPEN_STATUSES.includes(p.pipelineStatus)) return;
    const revs = [...(p.quoteRevisions || [])].sort((a, b) => b.revision - a.revision);
    const latest = revs[0];
    if (!latest) return;                       // nothing quoted yet, nothing to chase
    if (p.quoteChasePaused) return;            // deliberately held off

    // Every chase counts against the LATEST revision. Issuing a new revision
    // restarts the sequence, because the conversation genuinely restarted.
    const fus = (p.quotationFollowUps || []).filter(f => f.quoteRevisionId === latest.id);
    const sent = fus.length;
    const lastTouch = [latest.date, ...fus.map(f => f.date)].filter(Boolean).sort().pop();
    const days = daysBetween(lastTouch, t);
    const acct = (accounts || []).find(a => a.id === p.accountId);
    const step = quoteChaseStep(sent + 1);
    out.push({
      id: `chase-${p.id}`,
      projectId: p.id, projectName: p.name, project: p,
      accountName: acct ? acct.name : '',
      revision: latest, revLabel: `Rev ${latest.revision}`,
      amount: latest.amount || 0,
      lastTouch, daysSince: days, sentCount: sent,
      round: sent + 1, step,
      due: days >= QUOTE_CHASE_INTERVAL_DAYS,
      dueIn: QUOTE_CHASE_INTERVAL_DAYS - days,
      exhausted: sent >= QUOTE_CHASE_SEQUENCE.length,
    });
  });
  // Longest silence first — that is the one most at risk.
  return out.sort((a, b) => b.daysSince - a.daysSince);
}

// The contact a quotation is chased with. The Estimator leads, because that is
// who a bid goes to; the rest of the job's contacts follow.
function quoteChaseContact(project) {
  const order = ['Estimator', 'General Contractor', 'Owner', 'Developer', 'Architect', 'Designer', 'Billing Contact'];
  for (const role of order) {
    const c = (project.contacts || {})[role];
    if (c && c.email) return { role, name: c.person || c.company || role, email: c.email };
  }
  const extra = (project.additionalContacts || []).find(c => c.email);
  return extra ? { role: extra.label || 'Contact', name: extra.person || extra.company, email: extra.email } : null;
}

function fillChaseTemplate(text, chase) {
  return String(text || '')
    .replace(/\{project\}/g, chase.projectName)
    .replace(/\{revLabel\}/g, chase.revLabel)
    .replace(/\{client\}/g, chase.accountName || 'you');
}

// ── The LEON mark, for the top of every outgoing email ────────────────────
// The official lockup (the lion over LEON COLLECTION), rasterised white from
// `logo/leon-official.svg` and inlined so the email carries its own artwork
// rather than depending on a server we do not have.
//
// Be clear-eyed about where this shows: Apple Mail, most native phone clients
// and Thunderbird render a base64 image; **Gmail and Outlook strip it**. That is
// why the masthead keeps the tracked LEON wordmark underneath as real type —
// where the mark is blocked, the header still reads as LEON rather than as a
// broken image. When a real mail service exists, the same PNG goes on as a CID
// attachment and shows everywhere; only the `src` changes.
const EMAIL_LOGO_SRC = 'data:image/png;base64,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';
const EMAIL_LOGO_W = 46;    // display width in the masthead; the file is 214px for retina

function emailMasthead(coTagline) {
  const B = EMAIL_BRAND;
  return `<tr><td style="background:${B.black};padding:24px 34px 22px;text-align:center;">`
    + `<img src="${EMAIL_LOGO_SRC}" width="${EMAIL_LOGO_W}" alt="" `
    + `style="display:block;margin:0 auto 12px;width:${EMAIL_LOGO_W}px;height:auto;border:0;outline:none;">`
    + `<div style="font-family:${EMAIL_FONT};color:${B.white};font-size:30px;letter-spacing:13px;line-height:1;text-indent:13px;">LEON</div>`
    + `<div style="font-family:${EMAIL_FONT};color:${B.brownLight};font-size:9px;letter-spacing:5.5px;margin-top:8px;text-indent:5.5px;">${esc(String(coTagline || 'INTERIORS').toUpperCase())}</div>`
    + `</td></tr>`;
}

// ── The quotation follow-up email, in LEON's brand ────────────────────────
// This one goes to a CLIENT, not a colleague, so it is its own letter rather
// than the internal share email with different words: a LEON masthead, the
// quotation stated as a fact in a bordered card, the message, and a proper
// sign-off with the company's details under it.
//
// Two constraints shape every choice below, and they are not preferences:
//   1. Mail clients render table layouts with inline styles. Outlook ignores
//      <style> blocks, Gmail strips <head>, and flex and grid do not survive.
//   2. A mail client cannot be sent a font file, and Gmail and Outlook both
//      strip base64 images — so the wordmark is set as TRACKED TYPE, the same
//      lockup, drawn with letter-spacing. It renders identically everywhere.
//      When a real mail service exists the logo can be embedded by CID and
//      this is the one line that changes.
function buildQuoteFollowUpEmail(d) {
  const B = EMAIL_BRAND;
  const co = d.company || {};
  const coName = co.tradeName || co.name || 'LEON Integra';
  const first = String(d.recipientName || '').split(' ')[0] || 'there';
  const pad = inner => `<tr><td style="padding:0 34px;">${inner}</td></tr>`;
  const L = [];

  L.push(`<div style="background:${B.cream};padding:26px 12px;font-family:${EMAIL_FONT};">`);
  L.push(`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:620px;margin:0 auto;background:${B.white};border:1px solid ${B.line};border-radius:14px;overflow:hidden;">`);

  // Masthead
  L.push(emailMasthead(co.tagline));
  L.push(`<tr><td style="height:4px;background:${B.brown};"></td></tr>`);

  L.push(`<tr><td style="height:26px;"></td></tr>`);
  L.push(pad(`<p style="margin:0 0 16px;font-size:16px;color:${B.black};">Hi ${esc(first)},</p>`));
  L.push(pad(`<div style="font-size:14px;line-height:1.7;color:${B.black};">${escLines(d.message || '')}</div>`));

  // The quotation, stated plainly — the one fact the email is about.
  L.push(`<tr><td style="height:22px;"></td></tr>`);
  const rows = [
    ['Project', d.projectName],
    ['Quotation', [d.revLabel, d.revAmount].filter(Boolean).join(' — ')],
    ['Issued', d.revDate],
  ].filter(r => r[1]);
  L.push(pad(`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${B.cream};border-left:3px solid ${B.brown};border-radius:8px;">`
    + `<tr><td style="padding:16px 18px;">`
    + rows.map(([k, v]) =>
        `<div style="margin:0 0 6px;font-size:13px;color:${B.muted};">`
        + `<span style="display:inline-block;min-width:82px;font-size:10px;letter-spacing:1.4px;text-transform:uppercase;color:${B.brown};">${esc(k)}</span>`
        + `<span style="color:${B.black};font-weight:bold;">${esc(v)}</span></div>`).join('')
    + `</td></tr></table>`));

  // Sign-off. The uploaded signature banner is the real one — the same file
  // people already use in Outlook or Gmail — so it wins. The typed block only
  // stands in when there is no image.
  L.push(`<tr><td style="height:26px;"></td></tr>`);
  L.push(pad(`<p style="margin:0 0 4px;font-size:14px;color:${B.black};">Best regards,</p>`));
  if (d.senderSignatureImage) {
    L.push(`<tr><td style="height:8px;"></td></tr>`);
    L.push(pad(`<img src="${esc(d.senderSignatureImage)}" alt="${esc(d.senderName || '')}" `
      + `width="552" style="display:block;width:100%;max-width:552px;height:auto;border:0;outline:none;text-decoration:none;">`));
  } else if (d.senderSignature && d.senderSignature.trim()) {
    L.push(pad(`<div style="font-size:13px;line-height:1.6;color:${B.black};">${escLines(d.senderSignature)}</div>`));
  } else {
    L.push(pad(`<div style="font-size:13px;line-height:1.6;color:${B.black};">`
      + `<div style="font-weight:bold;">${esc(d.senderName || '')}</div>`
      + (d.senderTitle ? `<div style="color:${B.muted};">${esc(d.senderTitle)}</div>` : '')
      + `<div style="color:${B.brown};letter-spacing:1.2px;font-size:11px;text-transform:uppercase;margin-top:4px;">${esc(coName)}</div>`
      + `</div>`));
  }

  // Footer — how to reach us, and nothing else.
  L.push(`<tr><td style="height:26px;"></td></tr>`);
  L.push(`<tr><td style="border-top:1px solid ${B.line};background:${B.cream};padding:16px 34px;text-align:center;">`);
  // The company profile stores the office as two lines, not one `address`.
  const addr = [co.addressLine1, co.addressLine2].filter(Boolean).join(', ');
  const bits = [addr, co.phone, co.email, co.website].filter(Boolean).map(esc);
  L.push(`<div style="font-size:11px;color:${B.muted};line-height:1.7;">${bits.join(' &nbsp;·&nbsp; ')}</div>`);
  L.push(`</td></tr>`);
  L.push(`</table></div>`);

  return {
    subject: d.subject,
    body: buildFollowUpText(d),   // the plain-text half, and what a mailto: draft carries
    html: L.join(''),
  };
}

// LEON's own Century Gothic, for the PREVIEW only. A mail client cannot be sent
// a font file, so the email itself asks for the face by name and degrades — but
// there is no reason for the person composing it to be shown the fallback when
// the real face is sitting in the app. Same-origin, so the iframe previewing it
// needs `allow-same-origin`; it is never given `allow-scripts`.
function emailPreviewDocument(html) {
  return '<!doctype html><html><head><meta charset="utf-8">'
    + '<style>'
    + "@font-face{font-family:'Century Gothic Leon';src:url('fonts/CenturyGothicLeon.ttf') format('truetype');font-weight:400;font-display:swap;}"
    + "html,body{margin:0;padding:0;}"
    // Win the inline font-family on every element, so the preview shows LEON's
    // face rather than the recipient-side fallback stack.
    + "body *{font-family:'Century Gothic Leon'," + EMAIL_FONT.replace(/'/g, "'") + " !important;}"
    + '</style></head><body>' + html + '</body></html>';
}


// ═══════════════════════════ Door opening calculation engine
// Rough Opening <-> Frame Overall <-> Door Leaf, in BOTH directions, from a
// rule that lives in the library rather than in this file. There is deliberately
// no universal "leaf + 2 inches" anywhere here: the clearances come from the
// frame system, and a hollow metal frame, a split jamb and a concealed frame do
// not stack up the same way.
//
// The chain, per edge:
//   leaf  --(leafToFrame)-->  frame overall  --(frameToRo)-->  rough opening
// Inverting it is just subtraction, which is why the same rule serves both
// starting methods and the two can never disagree.
function doorLeafSpan(leafW, rule, handing) {
  // A pair is two leaves plus whatever sits between them.
  const pair = handing === 'PAIR' || handing === 'PAIR_UNEQUAL';
  return pair ? leafW * 2 + qnum(rule.astragal) : leafW;
}

function computeFromLeaf(leaf, rule, handing) {
  const r = rule;
  const span = doorLeafSpan(leaf.w, r, handing);
  const frameW = span + 2 * qnum(r.leafToFrameJambEach);
  const frameH = leaf.h + qnum(r.leafToFrameHead) + qnum(r.leafToFrameSill);
  return {
    leaf: { w: leaf.w, h: leaf.h },
    frame: { w: frameW, h: frameH },
    ro: { w: frameW + 2 * qnum(r.frameToRoJambEach),
          h: frameH + qnum(r.frameToRoHead) + qnum(r.frameToRoSill) },
  };
}

function computeFromRo(ro, rule, handing) {
  const r = rule;
  const frameW = ro.w - 2 * qnum(r.frameToRoJambEach);
  const frameH = ro.h - qnum(r.frameToRoHead) - qnum(r.frameToRoSill);
  const span = frameW - 2 * qnum(r.leafToFrameJambEach);
  const pair = handing === 'PAIR' || handing === 'PAIR_UNEQUAL';
  const leafW = pair ? (span - qnum(r.astragal)) / 2 : span;
  return {
    leaf: { w: leafW, h: frameH - qnum(r.leafToFrameHead) - qnum(r.leafToFrameSill) },
    frame: { w: frameW, h: frameH },
    ro: { w: ro.w, h: ro.h },
  };
}

// One entry point, so a door never has two different ideas of its own size.
// `method` records which end the user typed into; the other two are derived and
// stay derived until someone deliberately overrides them.
function computeDoorSizes(door, rule) {
  const r = rule || makeOpeningRule();
  const h = door.handing;
  const base = door.sizeMethod === 'ro'
    ? computeFromRo({ w: qnum(door.roW), h: qnum(door.roH) }, r, h)
    : computeFromLeaf({ w: qnum(door.leafW), h: qnum(door.leafH) }, r, h);
  // An override wins over the calculation, but only where one was actually set,
  // and the screen has to say so — a silently overridden dimension is how a
  // wrong door gets built.
  const ov = door.overrides || {};
  const out = {
    leaf: { w: qpick(ov.leafW, base.leaf.w), h: qpick(ov.leafH, base.leaf.h) },
    frame: { w: qpick(ov.frameW, base.frame.w), h: qpick(ov.frameH, base.frame.h) },
    ro: { w: qpick(ov.roW, base.ro.w), h: qpick(ov.roH, base.ro.h) },
    calculated: base,
    overridden: Object.keys(ov).filter(k => ov[k] !== null && ov[k] !== undefined && ov[k] !== ''),
    undercut: qnum(r.undercut),
    thickness: qnum(door.leafThickness),
  };
  out.leafArea = (out.leaf.w * out.leaf.h) / 1e6;   // m² per leaf
  return out;
}

// Warnings, never blocks. An authorised user can proceed with a reason, which
// is logged — the app is not in a position to know more than the person about a
// real opening, but it is in a position to notice.
function validateDoor(door, rule, ctxLike) {
  const r = rule || makeOpeningRule();
  const s = computeDoorSizes(door, r);
  const out = [];
  const add = (level, msg) => out.push({ level, msg });
  if (!(s.leaf.w > 0) || !(s.leaf.h > 0)) add('error', 'Leaf size does not resolve to a positive dimension — check the opening rule and the entered size.');
  if (s.ro.w < s.frame.w) add('error', 'Required rough opening is smaller than the frame it has to receive.');
  if (s.ro.h < s.frame.h) add('error', 'Required rough opening height is smaller than the frame.');
  if (r.maxLeafWidth && s.leaf.w > qnum(r.maxLeafWidth)) add('warn', `Leaf is wider than the maximum this frame system allows (${Math.round(qnum(r.maxLeafWidth))} mm).`);
  if (r.maxLeafHeight && s.leaf.h > qnum(r.maxLeafHeight)) add('warn', `Leaf is taller than the maximum this frame system allows (${Math.round(qnum(r.maxLeafHeight))} mm).`);
  if (door.wallThickness && door.frameDepth && qnum(door.frameDepth) < qnum(door.wallThickness))
    add('warn', 'Frame depth is less than the wall thickness — check the frame profile against the wall build-up.');
  // A rating that was asked for is not a rating that was certified.
  if (door.fireRating && door.fireRating !== 'None' && door.ratingState !== 'Certified')
    add('info', `Fire rating ${door.fireRating} is ${String(door.ratingState || 'requested').toLowerCase()}, not certified — no certification document is on file.`);
  if (door.fireRating && door.fireRating !== 'None' && !(door.ratingDocUrl))
    add('warn', 'A fire rating is specified but no certification document is attached.');
  return { sizes: s, issues: out, ok: !out.some(i => i.level === 'error') };
}

// ── Architect door schedule import ────────────────────────────────────────
// The aliases are what architects actually write on a door schedule, gathered
// from real ones: "DOOR NO.", "MK", "LEAF W", "RATING", "HDW SET". Getting these
// right is the difference between a schedule that maps itself and one the user
// has to wire column by column, a hundred rows in.
const DOOR_IMPORT_FIELDS = [
  { key: 'mark', label: 'Door mark', required: true,
    aliases: ['door mark', 'door no', 'door number', 'mark', 'mk', 'door', 'door id', 'tag', 'no'] },
  { key: 'typeCode', label: 'Door type', required: false,
    aliases: ['door type', 'type', 'dr type', 'type mark', 'door type mark'] },
  { key: 'qty', label: 'Quantity', required: false,
    aliases: ['qty', 'quantity', 'count', 'no of doors'] },
  { key: 'leafW', label: 'Leaf width', required: false,
    aliases: ['width', 'leaf width', 'door width', 'w', 'nominal width', 'size w'] },
  { key: 'leafH', label: 'Leaf height', required: false,
    aliases: ['height', 'leaf height', 'door height', 'h', 'nominal height', 'size h'] },
  { key: 'leafThickness', label: 'Thickness', required: false,
    aliases: ['thickness', 'thk', 'door thickness', 'leaf thickness'] },
  { key: 'size', label: 'Size (combined)', required: false,
    aliases: ['size', 'door size', 'nominal size', 'dimensions', 'w x h', 'wxh'] },
  { key: 'roW', label: 'Rough opening width', required: false,
    aliases: ['rough opening width', 'ro width', 'ro w', 'masonry opening width'] },
  { key: 'roH', label: 'Rough opening height', required: false,
    aliases: ['rough opening height', 'ro height', 'ro h', 'masonry opening height'] },
  { key: 'frame', label: 'Frame', required: false,
    aliases: ['frame', 'frame type', 'frame mark', 'jamb', 'frame material'] },
  { key: 'material', label: 'Material', required: false,
    aliases: ['material', 'door material', 'leaf material', 'construction', 'core'] },
  { key: 'finish', label: 'Finish', required: false,
    aliases: ['finish', 'door finish', 'colour', 'color', 'paint'] },
  { key: 'handing', label: 'Handing', required: false,
    aliases: ['handing', 'hand', 'swing', 'door swing', 'lh rh', 'operation'] },
  { key: 'hardwareSet', label: 'Hardware set', required: false,
    aliases: ['hardware set', 'hardware', 'hw set', 'hdw set', 'hw', 'hardware group'] },
  { key: 'fireRating', label: 'Fire rating', required: false,
    aliases: ['fire rating', 'rating', 'label', 'fire label', 'ul label', 'fire'] },
  { key: 'location', label: 'Location', required: false,
    aliases: ['location', 'room', 'space', 'area', 'description'] },
  { key: 'fromRoom', label: 'From room', required: false,
    aliases: ['from', 'from room', 'room from'] },
  { key: 'toRoom', label: 'To room', required: false,
    aliases: ['to', 'to room', 'room to'] },
  { key: 'notes', label: 'Notes', required: false,
    aliases: ['notes', 'remarks', 'comments', 'note'] },
];

// Architects abbreviate. These are the ones worth reading — and every one is a
// SUGGESTION shown to the user, never a silent substitution, because "SCWD"
// meaning solid core wood door is a convention, not a guarantee.
const DOOR_ABBREVIATIONS = {
  'scwd': 'Solid Core Wood Door', 'sc': 'Solid Core', 'hcwd': 'Hollow Core Wood Door',
  'hm': 'Hollow Metal', 'hmf': 'Hollow Metal Frame', 'wd': 'Wood', 'alum': 'Aluminum',
  'ins': 'Insulated', 'flush': 'Flush', 'gl': 'Glazed', 'fg': 'Full Glass',
  'pr': 'Pair', 'sgl': 'Single', 'db': 'Double',
};
function expandDoorAbbrev(v) {
  const k = String(v || '').trim().toLowerCase();
  return DOOR_ABBREVIATIONS[k] || null;
}

// "3'-0" x 7'-0"" or "36 x 84" in one cell — very common, and worth splitting
// rather than making someone retype 100 of them into two columns.
function splitDoorSize(text, system) {
  const s = String(text || '').trim();
  if (!s) return null;
  const m = s.split(/\s*[x×]\s*/i);
  if (m.length !== 2) return null;
  const w = parseDim(m[0], system), h = parseDim(m[1], system);
  return (w && h) ? { w, h } : null;
}

const DOOR_HANDING_ALIASES = {
  lh: 'LH', 'left': 'LH', 'left hand': 'LH', 'lhs': 'LH',
  rh: 'RH', 'right': 'RH', 'right hand': 'RH', 'rhs': 'RH',
  lhr: 'LHR', 'left hand reverse': 'LHR', 'lhrb': 'LHR',
  rhr: 'RHR', 'right hand reverse': 'RHR', 'rhrb': 'RHR',
  pair: 'PAIR', 'pr': 'PAIR', 'double': 'PAIR', 'db': 'PAIR',
};
function normaliseHanding(v) {
  const k = String(v || '').trim().toLowerCase();
  return DOOR_HANDING_ALIASES[k] || null;
}

const DOOR_RATING_ALIASES = {
  '20': '20 Min', '20 min': '20 Min', '20min': '20 Min', "20'": '20 Min',
  '30': '30 Min', '45': '45 Min', '45 min': '45 Min', '60': '60 Min', '60 min': '60 Min',
  '90': '90 Min', '90 min': '90 Min', '180': '3 Hour', '3 hr': '3 Hour', '3 hour': '3 Hour',
  'none': 'None', 'n/a': 'None', '-': 'None', 'nr': 'None', 'non-rated': 'None',
};
function normaliseFireRating(v) {
  const k = String(v || '').trim().toLowerCase();
  return DOOR_RATING_ALIASES[k] || (DOOR_FIRE_RATINGS.includes(String(v || '').trim()) ? String(v).trim() : null);
}

// Read a schedule row into a door, and say for every field WHERE the value came
// from and how confident that is. A row that imported with a guessed handing and
// a guessed rating is not the same as one that read cleanly, and the review
// screen has to be able to tell them apart.
function readDoorScheduleRow(row, map, system, types) {
  const get = k => (map[k] ? String(row[map[k]] === undefined ? '' : row[map[k]]).trim() : '');
  const notes = [];
  const out = { qty: 1 };

  out.mark = get('mark');
  if (!out.mark) notes.push({ level: 'error', msg: 'No door mark — the row cannot be identified.' });

  const q = parseFloat(get('qty'));
  if (isFinite(q) && q > 0) out.qty = q;

  let w = parseDim(get('leafW'), system);
  let h = parseDim(get('leafH'), system);
  if ((!w || !h) && get('size')) {
    const sz = splitDoorSize(get('size'), system);
    if (sz) { w = w || sz.w; h = h || sz.h; notes.push({ level: 'info', msg: `Size split from "${get('size')}".` }); }
  }
  if (w) out.leafW = w;
  if (h) out.leafH = h;
  const t = parseDim(get('leafThickness'), system);
  if (t) out.leafThickness = t;

  const rw = parseDim(get('roW'), system), rh = parseDim(get('roH'), system);
  if (rw && rh) { out.roW = rw; out.roH = rh; out.sizeMethod = 'ro'; }
  else if (w && h) out.sizeMethod = 'leaf';

  const hand = normaliseHanding(get('handing'));
  if (get('handing')) {
    if (hand) out.handing = hand;
    else notes.push({ level: 'warn', msg: `Handing "${get('handing')}" was not recognised — left unset.` });
  }

  const fr = normaliseFireRating(get('fireRating'));
  if (get('fireRating')) {
    if (fr) { out.fireRating = fr; out.ratingState = 'Requested'; }
    else notes.push({ level: 'warn', msg: `Fire rating "${get('fireRating')}" was not recognised — left unset.` });
  }

  ['location', 'fromRoom', 'toRoom', 'notes'].forEach(k => { if (get(k)) out[k] = get(k); });

  // Abbreviations are SUGGESTED, never applied — the row carries the suggestion
  // for a person to accept.
  const suggestions = [];
  ['material', 'frame', 'finish'].forEach(k => {
    const raw = get(k);
    if (!raw) return;
    const full = expandDoorAbbrev(raw);
    if (full) suggestions.push({ field: k, raw, suggested: full });
  });

  // Match the row to a door type by its code, then by name.
  const tc = get('typeCode');
  let matched = null;
  if (tc) {
    const k = tc.toLowerCase().trim();
    matched = types.find(x => String(x.code || '').toLowerCase().trim() === k)
      || types.find(x => String(x.code || '').toLowerCase().replace(/^type\s*/, '') === k.replace(/^type\s*/, ''))
      || types.find(x => String(x.name || '').toLowerCase().includes(k));
  }
  return { door: out, typeCode: tc, matchedTypeId: matched ? matched.id : null,
           matchedTypeCode: matched ? matched.code : '', suggestions, notes,
           ok: !notes.some(n => n.level === 'error') };
}

// Hand a written brief to the assistant this person chose. Three steps, in the
// order of how reliable they are:
//   1. Copy the FULL brief to the clipboard. This always works, and it is what
//      makes the other two steps safe to attempt — nothing is lost if a URL is
//      truncated or an app never opens.
//   2. Try the desktop app's URL scheme in a hidden iframe. If the app is not
//      installed the browser ignores it and nothing visible happens.
//   3. Open the web URL with the brief in the query string. On macOS an
//      installed desktop app usually claims its own domain and takes this.
// Returns what actually happened so the UI can say it rather than guess.
function openInAi(prompt, providerKey) {
  const p = aiProviderByKey(providerKey);
  const text = String(prompt || '');
  let copied = false;
  try {
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text);
      copied = true;
    }
  } catch (e) { copied = false; }
  if (p.scheme) {
    try {
      const f = document.createElement('iframe');
      f.style.display = 'none';
      f.src = p.scheme + (p.schemeLimit ? encodeURIComponent(text.slice(0, p.schemeLimit)) : '');
      document.body.appendChild(f);
      setTimeout(() => { try { f.remove(); } catch (e) {} }, 1200);
    } catch (e) {}
  }
  const truncated = text.length > p.webLimit;
  try { window.open(p.web + encodeURIComponent(text.slice(0, p.webLimit)), '_blank', 'noopener'); } catch (e) {}
  rememberAiProvider(p.key);
  return { provider: p, copied, truncated };
}
// The brief itself. One shape for every AI hand-off in the app, so a request
// raised from a PDF reads like one raised from a drawing set: what is wanted,
// what it concerns, and — said plainly — that the file has to be attached on
// the other side, because a URL cannot carry a document.
function buildAiHandoffBrief({ task, detail, context, company, instructions, files }) {
  const lines = [];
  lines.push(task);
  if (detail) lines.push('', detail);
  const ctxLines = (context || []).filter(c => c && c.value);
  if (ctxLines.length) {
    lines.push('', 'Context');
    ctxLines.forEach(c => lines.push('- ' + c.label + ': ' + c.value));
  }
  if (instructions && instructions.trim()) {
    lines.push('', 'Notes from ' + (company && company.companyName ? company.companyName : 'us'), instructions.trim());
  }
  if (files && files.length) {
    lines.push('', 'Attach these before answering — they are not included in this message:');
    files.forEach(f => lines.push('- ' + f));
  }
  return lines.join('\n');
}

// ── The client's half of a quote ────────────────────────────────────────────
// A quote analysis holds cost, overhead, freight, duty, commission, bonus,
// margin and vendor. A client may see NONE of it. This builds the client's
// document by WHITELIST — it names the handful of fields that may travel and
// copies those — rather than by taking the internal object and deleting what is
// private. A whitelist fails closed: a field added to the analysis tomorrow is
// absent from the client document until someone deliberately adds it. A
// blacklist fails open, and the failure is silent and in front of a client.
//
// The shape follows the workbook's own client-facing columns: per scope a
// Subtotal, an Average per Unit, Taxes and a Contract Value.
const CLIENT_QUOTE_FORBIDDEN = [
  'cost', 'unitCost', 'mat', 'matUnit', 'labor', 'laborUnit', 'install', 'installUnit',
  'overhead', 'overheadPct', 'freight', 'freightPct', 'freightPerContainer', 'duty', 'dutyPct',
  'margin', 'marginPct', 'markup', 'markupAmt', 'commission', 'commissionPct',
  'bonus', 'bonusPct', 'profit', 'cm', 'ta', 'vendorId', 'ratePct', 'containers',
  'slabs', 'slabRate', 'c2s', 'unitPrice', 'driverQty',
];
function clientQuoteFromAnalysis(qa, opts) {
  const o = opts || {};
  const detail = o.detail || 'scope';          // 'scope' | 'line'
  const unitCount = qnum(o.unitCount != null ? o.unitCount : qa.unitCount);
  const taxRate = qnum(o.taxRatePct != null ? o.taxRatePct : qa.taxRatePct);
  const showSelections = o.showSelections !== false;

  const sections = (qa.sections || []).map(sec => {
    const t = quoteSectionTotals(sec, qa);
    const lines = detail === 'line'
      ? (sec.lines || [])
          .filter(l => !l.excluded)
          .map(l => {
            // An area heading and a note are STRUCTURE, not priced lines. They
            // travelled as blank rows with a quantity of zero and no price,
            // which reads to a client as an item we forgot to price. They carry
            // their text and nothing else, and the reader renders them as the
            // heading and the note they are — which is why they were added.
            if (!quoteRowIsItem(l)) {
              return { rowKind: l.rowKind, description: l.description || '' };
            }
            const lt = quoteLineTotals(l, qa, sec);
            const row = {
              area: l.area || '',
              description: l.description || l.itemTag || '',
              qty: lt.qty,
              uom: l.uom || sec.uom || '',
              price: lt.sell,
            };
            // The selection is the one internal field a client SHOULD see — it
            // is what they are buying. Only its public face travels: the name,
            // the picture and the category, never the supplier or the code.
            // A line carries a LIST of finishes; the singular field is read for
            // anything saved before that.
            const refs = Array.isArray(l.finishRefs) && l.finishRefs.length
              ? l.finishRefs : (l.finishRef ? [l.finishRef] : []);
            if (showSelections && refs.length && typeof clientSafeFinish === 'function') {
              const safe = refs.map(clientSafeFinish).filter(Boolean);
              if (safe.length) { row.selections = safe; row.selection = safe[0]; }
            }
            // The RESOLVED specification — the area's answers with the line's
            // own over them. Reading `l.specs` alone would drop everything
            // answered once at area level, which is now where most of it is.
            const rs = (typeof quoteResolvedSpecs === 'function')
              ? quoteResolvedSpecs(sec, l) : { specs: l.specs || {}, refs: l.specRefs || {} };
            if (showSelections && Object.keys(rs.specs).length) {
              row.specs = Object.assign({}, rs.specs);
            }
            // A specification answered FROM the catalog carries a picture, and a
            // client reads a finish board rather than a list of finish names.
            // Those pictures join the selections so they print and reach the
            // presentation. Every one goes through clientSafeFinish — name,
            // picture and category only — so the supplier and the code behind
            // the specification stay ours, exactly as they do for `finishRefs`.
            if (showSelections && Object.keys(rs.refs).length && typeof clientSafeFinish === 'function') {
              const allow = (o && o.specImageFields) ? (o.specImageFields[sec.scopeKey || sec.name] || null) : null;
              const picks = Object.keys(rs.refs)
                .filter(f => !allow || allow.indexOf(f) >= 0)
                .map(f => { const safe = clientSafeFinish(rs.refs[f]); return safe && safe.img ? Object.assign({ field: f }, safe) : null; })
                .filter(Boolean);
              if (picks.length) {
                row.specSelections = picks;
                row.selections = (row.selections || []).concat(picks);
                if (!row.selection) row.selection = picks[0];
              }
            }
            return row;
          })
      : [];
    const subtotal = t.sell;
    return {
      name: sec.name || '',
      // The trade, so a reader can key off it — which colour range this scope
      // offers, which lead times it follows. It is the name of a trade, not a
      // cost or a source, so it is inside the whitelist rather than stripped;
      // `specImageFields` already looked it up here and never found it.
      scopeKey: sec.scopeKey || '',
      kind: sec.kind || 'supply',
      note: sec.note || '',
      lines,
      subtotal,
      averagePerUnit: unitCount ? subtotal / unitCount : null,
      taxes: subtotal * taxRate,
      contractValue: subtotal * (1 + taxRate),
      unpriced: t.unpriced,
    };
  }).filter(sec => sec.subtotal > 0 || sec.unpriced > 0);

  const subtotal = sections.reduce((n, s) => n + s.subtotal, 0);
  const unpriced = sections.reduce((n, s) => n + s.unpriced, 0);
  return {
    name: qa.name || 'Quotation',
    revision: qa.revision || 1,
    date: qa.issuedDate || qa.createdDate || todayISO(),
    currency: qa.currency || 'USD',
    preparedBy: qa.preparedBy || '',
    assumptions: qa.assumptions || '',
    exclusions: qa.exclusions || '',
    unitCount: unitCount || null,
    taxRatePct: taxRate,
    detail, sections, subtotal,
    taxes: subtotal * taxRate,
    contractValue: subtotal * (1 + taxRate),
    // An unpriced line makes the quotation INCOMPLETE rather than cheap — the
    // same rule the BOQ follows. A blank is not zero.
    unpriced,
  };
}
// ─── The quotation deck plan ─────────────────────────────────────────────────
// Turns a CLIENT document into the ordered slide list that Leon's own issued
// quotation follows. It reads the client document and never the analysis: that
// whitelist is what keeps cost, margin, commission, freight and the supplier
// behind a finish out of a client-facing file, and it fails closed. A deck
// assembled from the analysis would be a second, unguarded way out of the
// building for exactly those figures.
//
// The order per scope is read off the issued deck and is the same for all six
// scopes in it:  divider -> design intent -> specification -> [colour options]
// -> gallery -> estimate,  then the labour scope as  divider -> install.
function quoteDeckPlan(doc, opts) {
  const o = opts || {};
  const out = [];
  const push = (archetype, data) => {
    const a = quoteDeckArchetype(archetype);
    out.push(Object.assign({
      archetype,
      label: a ? a.label : archetype,
      pictures: a ? a.pictures : 0,
    }, data || {}));
  };

  push('cover', { title: o.projectName || doc.name || 'Quotation', scope: null });
  const wantPlans = o.projectPlans !== false;
  // The front matter writes itself from records the Hub already holds — the
  // Company Profile, and the jobs actually published to the Finished Projects
  // library. In the issued deck these were typed and pasted, which is how a
  // reference list ends up naming a job we finished three years ago and none
  // since.
  // Every section gets its break page, not only the trades — About Us,
  // References, Project Plans, the Bid Summary and the Terms all open with one
  // in the issued decks, and LEON has standard artwork for each.
  if (o.company) {
    push('divider', { scope: null, title: 'About Us' });
    push('about', { scope: null, company: o.company });
  }
  const refs = (o.references && o.references.length) ? o.references
    : (typeof QUOTE_ART_REFERENCES !== 'undefined' ? QUOTE_ART_REFERENCES.map(r => ({
        id: r.id, name: r.location, location: '', img: r.img })) : []);
  if (refs.length) {
    push('divider', { scope: null, title: 'References' });
    // Nine to a slide, the 3x3 the issued deck uses.
    for (let i = 0; i < refs.length; i += 9) {
      push('refs', { scope: null, references: refs.slice(i, i + 9) });
    }
  }

  // Supply and labour are separate scopes on a quotation, and the deck shows
  // them apart too: a supply scope gets the estimate treatment, a labour scope
  // the installation package. Pairing them by name is what puts "Casework
  // Installation" straight after "Casework" rather than at the end.
  const sections = (doc.sections || []).slice();
  const baseName = n => String(n || '').replace(/\s+[—-]\s+(Supply|Labor|Labour|Install\w*).*$/i, '').trim();
  const supply = sections.filter(s => s.kind !== 'labor');
  const labour = sections.filter(s => s.kind === 'labor');
  const usedLabour = {};

  if (wantPlans) {
    push('divider', { scope: null, title: 'Project Plans' });
    push('plans', { scope: null });
  }
  supply.forEach(sec => {
    const key = baseName(sec.name);
    // A trade whose areas each get their own section — casework opens KITCHEN
    // CASEWORK, specifies it, then opens BATHROOM CASEWORK. One priced scope,
    // two sections, which is how every issued deck reads it.
    const areaBreaks = (typeof quoteDeckAreaBreaks === 'function')
      && quoteDeckAreaBreaks(sec.scopeKey || key)
      && quoteDeckSpecByArea(sec).filter(g => g.title).length > 1;
    if (areaBreaks) {
      quoteDeckSpecByArea(sec).forEach(g => {
        const title = g.title ? (g.title + ' ' + key) : key;
        push('divider', { scope: sec.name, title });
        push('intent',  { scope: sec.name, title });
        quoteDeckSpecSlides(sec, sec.scopeKey || key)
          .filter(x => x.title === g.title || String(x.title || '').indexOf(g.title + ' —') === 0)
          .forEach(x => push('spec', { scope: sec.name, title,
            specTitle: String(x.title || '').replace(g.title + ' — ', ''),
            specs: x.specs, specPics: quoteDeckSpecPictures(sec, g.title) }));
      });
    } else {
    push('divider', { scope: sec.name, title: key });
    push('intent',  { scope: sec.name, title: key });
    quoteDeckSpecSlides(sec, sec.scopeKey || key).forEach(g => {
      push('spec', { scope: sec.name, title: key, specTitle: g.title, specs: g.specs,
        specPics: quoteDeckSpecPictures(sec, g.title) });
    });
    }
    // Keyed the way the rest of the app keys a scope — quoteScopeSpecKey turns
    // "Countertops", "Countertop / Stone" and "Countertop — Supply" all into
    // `countertop`, which is what the recipe and specification libraries use.
    // Keying on the raw name would need the caller to spell it exactly as this
    // one quotation happens to, which is how a colour board silently vanishes.
    const ckey = (typeof quoteScopeSpecKey === 'function')
      ? quoteScopeSpecKey(sec.scopeKey || key) : String(sec.scopeKey || key).toLowerCase();
    const colours = (o.colourOptions && (o.colourOptions[ckey] || o.colourOptions[sec.scopeKey] || o.colourOptions[key])) || null;
    if (colours && colours.length) {
      const safe = colours.map(quoteDeckSafeSwatch).filter(Boolean);
      // Ten to a slide, which is the 5x2 grid the issued deck uses.
      for (let i = 0; i < safe.length; i += 10) {
        push('colors', { scope: sec.name, title: key, swatches: safe.slice(i, i + 10) });
      }
    }
    push('gallery',  { scope: sec.name, title: key });
    push('estimate', { scope: sec.name, title: key, section: sec });

    const mate = labour.find((l, i) => !usedLabour[i] && baseName(l.name) === key);
    if (mate) {
      usedLabour[labour.indexOf(mate)] = true;
      push('divider', { scope: mate.name, title: key + ' Installation' });
      push('install', { scope: mate.name, title: key + ' Installation', section: mate });
    }
  });
  // A labour scope with no supply half still has to appear, or it is quoted and
  // never shown. Reaching this means the pairing found no match, not that the
  // scope is unimportant.
  labour.forEach((l, i) => {
    if (usedLabour[i]) return;
    push('divider', { scope: l.name, title: l.name });
    push('install', { scope: l.name, title: l.name, section: l });
  });

  push('divider', { scope: null, title: 'Project Bid Summary' });
  push('summary', { scope: null, sections: doc.sections || [] });
  if (o.terms && o.terms.clauses && o.terms.clauses.length) {
    push('divider', { scope: null, title: 'Terms and Conditions' });
    push('terms', { scope: null, terms: o.terms });
  }
  push('back', { scope: null });
  // The contents page is generated LAST and inserted second, because it can
  // only list the deck once the deck exists. In the issued decks it was a
  // pasted picture of a typed list, which is why it disagreed with the slides
  // behind it as soon as a scope moved.
  const toc = { archetype: 'toc', label: 'Table of contents', pictures: 0, scope: null };
  out.splice(1, 0, toc);
  return out;
}

// ─── an EDITED deck ──────────────────────────────────────────────────────────
// Every job is different, so the generated sequence is a STARTING POINT that
// the team then edits — a slide inserted here, one dropped there — while the
// format stays the same. What is stored on the quotation is the STRUCTURE only
// (`qa.deckSlides`: which slide, of which archetype, for which scope, in what
// order). Money, specifications and swatches are resolved from the live
// document at render time, so an edited deck still reprices itself and cannot
// drift from the quotation it belongs to.
// The key a stored row is matched back to a generated slide by. A scope can
// now carry SEVERAL slides of one archetype — casework specifies its base and
// its upper cabinets on separate pages — so archetype+scope alone is no longer
// unique, and using it silently dropped every spec slide after the first.
function quoteDeckKey(step, seen) {
  const base = (step.archetype + '|' + (step.scope || '') + '|' + (step.specTitle || '')).toLowerCase();
  const n = (seen[base] = (seen[base] || 0) + 1);
  return base + '#' + n;
}
function quoteDeckStructure(plan) {
  const seen = {};
  return (plan || []).map((s, i) => ({
    id: 'ds' + i + '-' + s.archetype + (s.scope ? '-' + String(s.scope).slice(0, 24) : ''),
    key: quoteDeckKey(s, seen),
    archetype: s.archetype,
    scope: s.scope || null,
    title: s.title || '',
    specTitle: s.specTitle || '',
    manual: false,
  }));
}

// Turn a stored structure back into a renderable plan against TODAY's document.
// Anything whose scope has since left the quotation is reported rather than
// silently dropped — a slide vanishing without a word is how a deck quietly
// stops matching what was sold.
function quoteDeckResolve(doc, opts, saved) {
  const o = opts || {};
  const generated = quoteDeckPlan(doc, o);
  if (!Array.isArray(saved) || !saved.length) {
    return { plan: generated, structure: quoteDeckStructure(generated), orphans: [], added: [] };
  }
  const byKey = {};
  const gseen = {};
  generated.forEach(g => { byKey[quoteDeckKey(g, gseen)] = g; });
  const used = {};
  const plan = [];
  const orphans = [];
  const sseen = {};
  saved.forEach(row => {
    // A row saved before slides carried a key still resolves: rebuild the same
    // key from its own fields, which is what quoteDeckKey does either way.
    const key = row.key || quoteDeckKey(row, sseen);
    if (row.key) { const b = key.split('#')[0]; sseen[b] = (sseen[b] || 0) + 1; }
    const g = byKey[key];
    if (g) {
      used[key] = true;
      plan.push(Object.assign({}, g, { title: row.title || g.title, _id: row.id }));
      return;
    }
    // Same archetype and scope, different part or area — the quotation changed
    // what it specifies since the deck was arranged. Fall back to the first
    // unused generated slide for that archetype+scope rather than orphaning it.
    if (!g) {
      const loose = Object.keys(byKey).find(k =>
        k.split('|')[0] === row.archetype.toLowerCase() &&
        k.split('|')[1] === String(row.scope || '').toLowerCase() && !used[k]);
      if (loose) {
        used[loose] = true;
        plan.push(Object.assign({}, byKey[loose], { _id: row.id }));
        return;
      }
    }
    // A scope-bound slide whose scope is gone cannot be priced or specified.
    if (row.scope && !(doc.sections || []).some(x => x.name === row.scope)) {
      orphans.push(row);
      return;
    }
    // A hand-inserted slide with no generated twin still renders — that is the
    // whole point of being able to add one.
    const a = quoteDeckArchetype(row.archetype);
    plan.push({
      archetype: row.archetype, label: a ? a.label : row.archetype,
      pictures: a ? a.pictures : 0, scope: row.scope || null,
      title: row.title || '', manual: true, _id: row.id,
      section: row.scope ? (doc.sections || []).find(x => x.name === row.scope) || null : null,
      terms: row.archetype === 'terms' ? o.terms : undefined,
      company: row.archetype === 'about' ? o.company : undefined,
      references: row.archetype === 'refs' ? o.references : undefined,
      // A hand-inserted slide carries its OWN content. That is the point of
      // it: an Accessories page — cutlery inserts, waste bins — belongs on the
      // quotation the client reads and nowhere near the analysis, which is
      // priced per module and would gain a column that is always blank.
      lines: row.lines || null,
      specTitle: row.specTitle || '',
      specs: Array.isArray(row.specs)
        ? row.specs.filter(r => r && (r.field || r.value))
            .map(r => ({ field: r.field || '', value: r.value || '' }))
        : [],
    });
  });
  // Scopes added to the quotation since the deck was last arranged.
  const aseen = {};
  const added = generated.filter(g => g.scope && !used[quoteDeckKey(g, aseen)]);
  return { plan, structure: saved, orphans, added };
}

// The quantities a scope carries, grouped by AREA — the breakdown that was a
// 5.9 MB pasted spreadsheet picture in the issued decks. It reads the area rows
// the estimator already put in the line list, so it cannot disagree with them.
function quoteDeckAreaRows(sec) {
  const out = [];
  let area = null;
  (sec && sec.lines || []).forEach(l => {
    if (l.rowKind === 'area') { area = { area: l.description || '', qty: 0, uom: '', price: 0, items: 0 }; out.push(area); return; }
    if (l.rowKind === 'note') return;
    const row = area || (out.length ? out[out.length - 1] : null) ||
      (out.push({ area: l.area || 'Unassigned', qty: 0, uom: '', price: 0, items: 0 }), out[out.length - 1]);
    row.qty += qnum(l.qty);
    row.price += qnum(l.price);
    row.items += 1;
    if (!row.uom && l.uom) row.uom = l.uom;
  });
  return out.filter(r => r.items > 0);
}

// The break-page artwork for a title. An admin override wins over the shipped
// set, so a scope whose standard picture should change is changed once under
// Quote Settings rather than re-placed on every quotation.
function quoteArtLookup(o, title) {
  const over = (o && o.artOverrides) || null;
  if (over && title) {
    const k = String(title).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
    if (over[k]) return over[k];
    const first = k.split('-')[0];
    if (over[first]) return over[first];
  }
  return (typeof quoteArtFor === 'function') ? quoteArtFor(title) : null;
}

// A colour swatch on its way to a client. The plan sanitises these ITSELF
// rather than trusting whatever the caller passed: a caller handing over raw
// catalog records is the easy mistake, and it publishes the supplier's own
// product code on a client-facing colour board. Only LEON's code, a name and a
// picture travel — the same three fields clientSafeFinish allows, for the same
// reason.
//
// KNOWN LIMITATION, and it is not fixable here: the picture is served from
// `finishes/<supplier>/…`, so the URL still names the supplier even though no
// field does. That is invisible in an exported deck, where the image is
// embedded, and visible in a live preview's DOM. Closing it needs the images
// served through a neutral path, which is a change to how every finish in the
// app is served and not something to do quietly inside a deck builder.
function quoteDeckSafeSwatch(f) {
  if (!f) return null;
  const safe = (typeof clientSafeFinish === 'function') ? (clientSafeFinish(f) || {}) : {};
  // A catalog record is marked with its supplier as `sup` (supplierCatalog) or
  // `supplier` (a stored reference) — testing only one of them was the bug that
  // let 27 VQ codes onto a client colour board.
  const fromSupplier = !!(f.sup || f.supplier);
  const code = f.leonCode || (fromSupplier ? '' : f.code) || '';
  // FAIL CLOSED: a supplier's product with no LEON code of its own cannot be
  // labelled on a client board without publishing the supplier's code, so it is
  // dropped rather than shown unlabelled or shown with theirs. These are
  // colours LEON does not sell under its own code anyway — the 27 in the
  // Vicrown range that its own quotation never offers.
  if (fromSupplier && !code) return null;
  const rawName = safe.name || f.name || '';
  return {
    code,
    // Where a record's only "name" is the supplier's code, show LEON's instead.
    name: (rawName && f.code && rawName === f.code) ? code : (rawName || code),
    img: safe.img || f.img || '',
    category: safe.category || f.cat || '',
  };
}

// The specification a scope's slide states: every field answered on the
// quotation, gathered from the lines that carry it. The AREA is where these are
// answered now, so most lines resolve to the same set — which is exactly why
// the slide states it ONCE per scope rather than once per line.
function quoteDeckScopeSpecs(sec) {
  const seen = {};
  const order = [];
  (sec.lines || []).forEach(l => {
    if (!l || !l.specs) return;
    Object.keys(l.specs).forEach(f => {
      const v = l.specs[f];
      if (v == null || v === '') return;
      if (!(f in seen)) { seen[f] = new Set(); order.push(f); }
      seen[f].add(String(v));
    });
  });
  return order.map(f => {
    const vals = Array.from(seen[f]);
    return {
      field: f,
      // Where the lines disagree the slide says so rather than picking one and
      // presenting a single answer the quotation does not actually make.
      value: vals.length === 1 ? vals[0] : vals.join(' / '),
      varies: vals.length > 1,
    };
  });
}

// The specification slides a scope needs — often more than one.
//
// Two splits, both read off what the quotation already says rather than
// declared separately:
//   1. by AREA, because a quotation that specifies its kitchen casework and its
//      bathroom casework differently is describing two products, and one slide
//      averaging them describes neither. The decks do exactly this (KITCHEN
//      CASEWORK then BATHROOM CASEWORK, UNIT ENTRANCE then UNIT INTERIOR DOORS).
//   2. by PART within an area — Base Cabinet / Upper Cabinet — from the field
//      prefixes the Quote Lines list already uses.
// Where a scope has one area and no parts this returns a single slide, which is
// what it did before.
function quoteDeckSpecSlides(sec, scopeKey) {
  const areas = quoteDeckSpecByArea(sec);
  const parts = (typeof quoteSpecPartsFor === 'function') ? quoteSpecPartsFor(scopeKey || sec.scopeKey || sec.name) : null;
  const out = [];
  areas.forEach(a => {
    if (!parts || !parts.length) { out.push({ title: a.title, specs: a.specs }); return; }
    const hasPart = f => parts.some(p => String(f.field || '').toLowerCase().indexOf(p.prefix.toLowerCase() + ' ') === 0);
    const common = a.specs.filter(f => !hasPart(f));
    let any = false;
    parts.forEach(p => {
      const pre = p.prefix.toLowerCase() + ' ';
      const mine = a.specs
        .filter(f => String(f.field || '').toLowerCase().indexOf(pre) === 0)
        // The prefix is how the field is FILED, not what it is called on a page
        // headed "Base Cabinet" — printing "Base Door Finish" there says it twice.
        .map(f => Object.assign({}, f, { field: String(f.field).slice(p.prefix.length).trim() }));
      if (!mine.length) return;
      any = true;
      out.push({ title: (a.title ? a.title + ' — ' : '') + p.label, specs: common.concat(mine) });
    });
    if (!any) out.push({ title: a.title, specs: a.specs });
  });
  return out.length ? out : [{ title: '', specs: [] }];
}

// The pictures a specification page shows: every spec field on that area that
// was answered FROM the catalog, each carrying the field it answers. They come
// through clientSafeFinish already — name, picture and category only — so the
// supplier and the code behind a specification stay ours.
function quoteDeckSpecPictures(sec, areaTitle) {
  const out = [];
  const seen = {};
  let area = null;
  (sec && sec.lines || []).forEach(l => {
    if (l.rowKind === 'area') { area = l.description || ''; return; }
    if (l.rowKind === 'note') return;
    if (areaTitle && area !== areaTitle) return;
    (l.specSelections || []).forEach(p => {
      if (!p || !p.img) return;
      const k = (p.field || '') + '|' + p.img;
      if (seen[k]) return;
      seen[k] = 1;
      out.push({ field: p.field || '', name: p.name || '', img: p.img });
    });
  });
  return out;
}

// The specification grouped by the AREA it was answered on. Areas are where the
// specification lives now, so this is a regrouping of what is already there —
// not a second place to answer it.
function quoteDeckSpecByArea(sec) {
  const groups = [];
  let cur = null;
  (sec && sec.lines || []).forEach(l => {
    if (l.rowKind === 'area') { cur = { title: l.description || '', lines: [] }; groups.push(cur); return; }
    if (l.rowKind === 'note') return;
    if (!cur) { cur = { title: '', lines: [] }; groups.push(cur); }
    cur.lines.push(l);
  });
  const built = groups
    .map(g => ({ title: g.title, specs: quoteDeckScopeSpecs({ lines: g.lines }) }))
    .filter(g => g.specs.length);
  if (!built.length) return [{ title: '', specs: quoteDeckScopeSpecs(sec) }];
  // One area, or several that all say the same thing, is ONE specification —
  // splitting it would print the same page three times under three headings.
  if (built.length > 1) {
    const sig = g => JSON.stringify(g.specs.map(f => f.field + '=' + f.value));
    const first = sig(built[0]);
    if (built.every(g => sig(g) === first)) return [{ title: '', specs: built[0].specs }];
  }
  return built;
}

// How many pictures a slide actually carries.
//
// The archetype's `pictures` is a STARTING count, not a limit: one job's design
// intent is a single photograph and another's is four, and a template that
// cannot grow forces the second job to be sent as the first. So the real count
// is whatever has been placed, plus one empty slot to place the next in — and
// never fewer than the archetype's own.
//
// An EMPTY trailing slot is deliberate. Without one there is no "+ Add
// picture" to press, and adding a picture would need a separate control that
// exists only to make the obvious one appear.
function quoteDeckSlotsFor(step, index, filled) {
  const base = step && step.pictures ? step.pictures : 0;
  if (!base) return 0;
  const f = filled || {};
  const prefix = step.archetype + ':' + index + ':';
  let highest = -1;
  Object.keys(f).forEach(k => {
    if (k.indexOf(prefix) !== 0) return;
    if (!f[k] || !f[k].url) return;
    const n = parseInt(k.slice(prefix.length), 10);
    if (!isNaN(n) && n > highest) highest = n;
  });
  return Math.max(base, highest + 2);
}

// How many picture slots the deck still has empty. A placeholder that survives
// to the client is the failure this is here to prevent, so it is counted and
// reported rather than left to be noticed.
function quoteDeckMissingPictures(plan, filled) {
  const f = filled || {};
  let n = 0;
  (plan || []).forEach((s, i) => {
    // Only the archetype's OWN slots count as missing. The extra trailing slot
    // is an invitation, not an obligation — counting it would mean a deck can
    // never read as complete however many pictures are placed.
    for (let k = 0; k < (s.pictures || 0); k++) {
      const slot = s.archetype + ':' + i + ':' + k;
      if (!f[slot] || !f[slot].url) n++;
    }
  });
  return n;
}

// The guard, so the rule is testable rather than merely intended: walks the
// finished client document and reports any key that should never have reached
// it. Used by the preview, which refuses to send when this is non-empty.
function clientQuoteLeaks(doc) {
  const found = new Set();
  const walk = v => {
    if (!v || typeof v !== 'object') return;
    if (Array.isArray(v)) return v.forEach(walk);
    Object.keys(v).forEach(k => {
      if (CLIENT_QUOTE_FORBIDDEN.indexOf(k) >= 0) found.add(k);
      walk(v[k]);
    });
  };
  walk(doc);
  return Array.from(found);
}

// Every contact the company knows, wherever they are filed. A job's contacts
// are NOT all the client's people — the architect belongs to a design practice,
// the GC to a builder, the estimator to whoever is bidding — so a picker
// limited to the job's own account can only ever offer the wrong half of them.
// The list is name-first, because that is how a person is looked for, and each
// entry carries the account it is filed under so the link is still visible.
function allContacts(accounts) {
  const out = [];
  (accounts || []).forEach(a => {
    (a.contacts || []).forEach(c => {
      out.push({ ...c, accountId: a.id, accountName: a.name });
    });
  });
  return out.sort((x, y) => String(x.name || '').localeCompare(String(y.name || '')));
}
function findContactEverywhere(accounts, contactId) {
  return allContacts(accounts).find(c => c.id === contactId) || null;
}

// ---------------------------------------------------------------------------
// LEON Sign — the engine
// ---------------------------------------------------------------------------
// Status is DERIVED from the recipients, never stored as an opinion of its own,
// so an envelope can never read Completed while somebody has not signed. The
// only statuses that override the derivation are the two that are decisions
// rather than progress: Voided (we stopped it) and Draft (not sent yet).
function signEnvelopeStatus(env) {
  if (!env) return 'Draft';
  if (env.status === 'Voided') return 'Voided';
  if (!env.sentDate) return 'Draft';
  const rs = env.recipients || [];
  if (rs.some(r => r.status === 'Declined')) return 'Declined';
  const signers = rs.filter(r => signRoleSigns(r.role));
  if (signers.length && signers.every(r => r.status === 'Signed')) return 'Completed';
  if (signers.some(r => r.status === 'Signed')) return 'Partially signed';
  if (rs.some(r => r.status === 'Viewed')) return 'Viewed';
  return 'Sent';
}

// Whose turn it is. Everyone on the same routing order is asked at once; a
// higher order is not asked until every signer below it is done. A copy or a
// viewer never holds the envelope up — they are sent at their step and the
// envelope moves on without them.
function signCurrentOrder(env) {
  const rs = (env && env.recipients) || [];
  const orders = [...new Set(rs.map(r => qnum(r.order) || 1))].sort((a, b) => a - b);
  for (const o of orders) {
    const blocking = rs.filter(r => (qnum(r.order) || 1) === o && signRoleSigns(r.role));
    if (blocking.some(r => r.status !== 'Signed' && r.status !== 'Declined')) return o;
  }
  return null;
}
function signWaitingOn(env) {
  const o = signCurrentOrder(env);
  if (o === null) return [];
  return (env.recipients || []).filter(r => (qnum(r.order) || 1) === o
    && signRoleSigns(r.role) && r.status !== 'Signed' && r.status !== 'Declined');
}

// What is still missing before this envelope can be sent. Returning the reasons
// rather than a boolean is what lets the button say why it is disabled — a
// disabled control with no explanation is the thing people file a bug about.
function signSendBlockers(env) {
  const out = [];
  if (!env) return ['No envelope.'];
  if (!(env.documents || []).length) out.push('No document attached.');
  const rs = env.recipients || [];
  if (!rs.length) out.push('No recipients.');
  rs.forEach(r => {
    if (!String(r.name || '').trim()) out.push('A recipient has no name.');
    else if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(r.email || '').trim()))
      out.push(`${r.name} has no valid email address.`);
  });
  const signers = rs.filter(r => signRoleSigns(r.role));
  if (!signers.length) out.push('Nobody on this envelope is being asked to sign.');
  signers.forEach(r => {
    const mine = (env.fields || []).filter(f => f.recipientId === r.id);
    if (!mine.some(f => f.type === 'signature' || f.type === 'initial'))
      out.push(`${r.name} has no signature field placed.`);
  });
  (env.fields || []).forEach(f => {
    if (!f.recipientId) out.push('A field is not assigned to anyone.');
  });
  return [...new Set(out)];
}

// Every required field a given recipient still has to fill.
function signOutstandingFields(env, recipientId) {
  return (env.fields || []).filter(f => f.recipientId === recipientId && f.required
    && !String(f.value || '').trim()
    && f.type !== 'dateSigned' && f.type !== 'name' && f.type !== 'company');
}

// A CONTENT HASH, which is the one piece of tamper-evidence a browser can
// honestly produce. It proves the bytes have not changed since they were sent.
// It does NOT prove when — the clock belongs to the signer's own machine — and
// nothing here is witnessed by a third party. Both halves are said on screen.
async function signHashBytes(bytes) {
  if (!bytes || typeof crypto === 'undefined' || !crypto.subtle) return null;
  const buf = await crypto.subtle.digest('SHA-256', bytes);
  return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
}
async function signHashDataUrl(url) {
  try {
    if (!url) return null;
    const res = await fetch(url);
    const buf = await res.arrayBuffer();
    return await signHashBytes(buf);
  } catch (e) { return null; }
}

// One line per envelope for a list or a report — the question every screen asks
// is "where is it and who is holding it up".
function signEnvelopeSummary(env) {
  const status = signEnvelopeStatus(env);
  const waiting = signWaitingOn(env);
  const signers = (env.recipients || []).filter(r => signRoleSigns(r.role));
  return {
    status,
    open: SIGN_OPEN_STATUSES.indexOf(status) >= 0,
    signed: signers.filter(r => r.status === 'Signed').length,
    signers: signers.length,
    waitingOn: waiting.map(r => r.name).filter(Boolean),
    binding: signIsBinding(env),
  };
}

// Everything waiting on one person, across every job — what the dashboard and
// the To-Do read. An envelope only reaches someone when it is actually their
// turn, or a queue shows work nobody can start.
function signMyQueue(envelopes, userId, email) {
  const mail = String(email || '').trim().toLowerCase();
  return (envelopes || []).filter(env => SIGN_OPEN_STATUSES.indexOf(signEnvelopeStatus(env)) >= 0)
    .map(env => ({ env, mine: signWaitingOn(env).filter(r =>
      (userId && r.userId === userId) || (mail && String(r.email || '').toLowerCase() === mail)) }))
    .filter(x => x.mine.length);
}
