// ═══════════════════════════════════════════════════════════════
// dispensary.jsx — the chart's Dispensary (Inventory Step 4)
// ═══════════════════════════════════════════════════════════════
//
// WHAT THIS IS (in plain English):
// Each patient chart now carries a fourth section, `dispensary`: the list of
// supplies, drugs, and the lens set aside for that case. It is built the day
// before from the worklist (procedure + eye → preference card; lens model +
// power → catalog lens; IOL-family extras), kept in step with the chart on
// surgery day (meds given, equipment used, lens scanned, procedure corrected,
// case cancelled), and finalized when the case is marked Complete.
//
// The ledger is the source of truth for quantities: "reserved" lines while
// the case is pending, "dispensed" lines once it is final. The SERVER works
// out the deltas (case-sync / case-finalize / case-unlock in inventory.js),
// so two iPads editing the same chart can never double-reserve.
//
// Same-day bilateral pairs share ONE dispensary, stored on the first eye's
// record; the fellow's record just points at it ({ sharedWith }).
//
// Loaded after inventory-stock.jsx and before the main app script, which
// calls into window.SurgSuiteDispensary at a handful of points (top-bar
// button, updatePatient, worklist import, Complete, the PDF).
// ═══════════════════════════════════════════════════════════════

const DispReact = React;

// ─── Reference data (catalog, cards, procedures, settings), cached ──
const DispRefs = {
  data: null, loadedAt: 0, loading: null,
  async load(force) {
    if (!force && this.data && Date.now() - this.loadedAt < 60000) return this.data;
    if (this.loading) return this.loading;
    this.loading = (async () => {
      try {
        const [products, prefcards, procedures, settings] = await Promise.all(['products', 'prefcards', 'procedures', 'settings'].map(s => InvApi.get(s)));
        this.data = { products: products.data || [], prefcards: prefcards.data || [], procedures: procedures.data || { list: [], config: {} }, settings: settings.data || {} };
        this.data.byId = Object.fromEntries(this.data.products.map(p => [p.id, p]));
        this.loadedAt = Date.now();
        window.__invProcConfig = this.data.procedures.config || {};
      } catch (e) { console.warn('Dispensary: reference data not loaded', e.message); }
      this.loading = null;
      return this.data;
    })();
    return this.loading;
  },
  get() { return this.data; },
};

// ─── Procedure type from the worklist text (same rule Heather's app used) ──
function dispProcKey(patient, eyeD) {
  const override = (eyeD && eyeD.opNote && eyeD.opNote.procedureType) || (patient.preop && patient.preop.procedureType);
  if (override) return override;
  const p = String(patient.procedure || '').toLowerCase();
  if (p.includes('smile')) return 'smile';
  if (p.includes('lasik')) return 'lasik';
  if (p.includes('prk')) return 'prk';
  if (p.includes('icl') || p.includes('collamer')) return 'icl';
  if (p.includes('rle') || p.includes('refractive lens') || p.includes('lens exchange')) return 'rle';
  return 'cataract';
}
// Which Settings → Procedures entry does this case match (type + eye)?
function dispPickProcedure(list, key, eye) {
  const same = (list || []).filter(pr => pr.active !== false && pr.key === key);
  return same.find(pr => pr.eye === eye) || same.find(pr => !pr.eye) || same[0] || null;
}
// The lens on the worklist → a catalog lens (model + power). Returns product or null.
function dispMatchLens(products, iolText, powerText) {
  const text = String(iolText || '').trim(); if (!text) return null;
  const powerNum = (() => { const m = String(powerText || '').match(/[+-]?\d+(?:\.\d+)?/); return m ? Number(m[0]) : null; })();
  const tokens = invNorm(text).replace(/[()]/g, ' ').split(/[^a-z0-9.+-]+/).filter(t => t.length >= 2);
  let best = null, bestScore = 0;
  for (const p of products) {
    if (!p.isIol || p.active === false) continue;
    const d = invNorm(p.description);
    if (powerNum != null) { const pm = String(p.iolPower || '').match(/[+-]?\d+(?:\.\d+)?/); if (!pm || Math.abs(Number(pm[0]) - powerNum) > 0.01) continue; }
    let score = 0; for (const t of tokens) if (d.includes(t)) score += t.length;
    if (score > bestScore) { best = p; bestScore = score; }
  }
  // Require the model code (longest token) to be present, otherwise it's a guess.
  const longest = tokens.slice().sort((a, b) => b.length - a.length)[0];
  if (best && longest && !invNorm(best.description).includes(longest)) return null;
  return best;
}
const dispSlug = (s) => 'm_' + invNorm(s).replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
// The chart's historical medication ids, so old charts keep reading.
const DISP_LEGACY_MED_IDS = { 'b.s.s 500ml': 'bss500', 'tetracaine 0.5% 1gtt': 'tetracaine', 'epi-lido 0.5ml injectable': 'epilido', 'duovisc': 'duovisc', 'moxifloxacin 0.1% 0.2ml inj': 'moxi', 'neo-poly-dex 1gtt': 'neopolydex', 'betadine 5% 1gtt': 'betadine5', 'b.s.s+': 'bssplus', 'trypan blue': 'trypanblue', 'miostat .1%': 'miostat', 'phen-lido 0.5ml injectable': 'phenlido', 'dex-moxi 0.2ml injectable': 'dexmoxi' };
const dispMedId = (name) => DISP_LEGACY_MED_IDS[invNorm(name)] || dispSlug(name);

// Lists the chart uses, from Settings → Procedures (fall back to the app's built-in lists until loaded).
function dispMedListFor(procKey, fallback) {
  const cfg = (window.__invProcConfig || {})[procKey];
  if (!cfg || !cfg.meds || !cfg.meds.length) return fallback;
  return cfg.meds.filter(m => m.name).map(m => ({ id: dispMedId(m.name), name: m.name, standard: !!m.standard, pullsToStock: !!m.pullsToStock, productId: m.productId || null }));
}
function dispEquipmentListFor(procKey, fallback) {
  const cfg = (window.__invProcConfig || {})[procKey];
  if (!cfg || !cfg.equipment || !cfg.equipment.length) return fallback;
  return cfg.equipment.filter(e => e.name).map(e => e.name);
}
function dispStandardEquipmentFor(procKey, fallback) {
  const cfg = (window.__invProcConfig || {})[procKey];
  if (!cfg || !cfg.equipment || !cfg.equipment.length) return fallback;
  return cfg.equipment.filter(e => e.standard).map(e => e.name);
}

// ─── Building the item list ────────────────────────────────────
// ctx: { patient, fellow (same-day pair, may be null), refs }
// Returns { cardId, cardName, procedureKey, items[] , notes[] } of AUTO items only.
function dispAutoItems({ patient, fellow, refs }) {
  const { products, prefcards, procedures, settings, byId } = refs;
  const items = [], notes = [];
  const eyesOf = (p) => (p.eye === 'OU' ? ['OD', 'OS'] : [p.eye || 'OD']);
  const records = fellow ? [patient, fellow] : [patient];
  const cancelled = records.every(r => (r.preop && r.preop.caseCancelled) || Object.values((r.intraop && r.intraop.eyes) || {}).some(e => e && e.caseCancelled));
  const procKey = dispProcKey(patient, patient.intraop && patient.intraop.eyes && patient.intraop.eyes[patient.eye || 'OD']);
  const eyeCode = fellow ? 'OU' : (patient.eye || '');
  const proc = dispPickProcedure(procedures.list, procKey, eyeCode) || dispPickProcedure(procedures.list, procKey, patient.eye || '');

  // 1. lens per eye (worklist text, or the scanned box if the OR scanned one)
  const lensFamilies = [];
  for (const rec of records) {
    for (const eye of eyesOf(rec)) {
      if (procKey === 'lasik' || procKey === 'smile' || procKey === 'prk') continue; // no implant
      const eyeD = (rec.intraop && rec.intraop.eyes && rec.intraop.eyes[eye]) || {};
      let lens = null, scan = null;
      if (eyeD.implantGtin) { const f = invFindByScan(products, eyeD.implantGtin); if (f.product && f.product.isIol) { lens = f.product; scan = eyeD; } }
      if (!lens && (eyeD.implantScannedModel || rec.iol)) lens = dispMatchLens(products, eyeD.implantScannedModel || rec.iol, eyeD.implantScannedPower || rec.iolPower);
      const fam = lens ? invIolFamilyOf(lens, settings.iolFamilyRules || {}) : null;
      if (fam) lensFamilies.push(fam);
      items.push({ key: `lens|${eye}`, source: 'lens', eye, productId: lens ? lens.id : null, description: lens ? lens.description : `${rec.iol || 'Lens'} ${rec.iolPower || ''}`.trim(), qty: 1, uom: 'Each', lot: eyeD.implantLot || '', serial: eyeD.implantSerial || '', expiry: dispIsoDate(eyeD.implantExpiry), unitCost: lens ? invLotUnitCost(lens, null) : null, needsProduct: !lens, family: fam || null, fromChartScan: !!scan });
    }
  }

  // 2. preference card: procedure default, but a lens-family card wins when the lens matches it
  let card = null;
  const famCards = prefcards.filter(c => c.active !== false && (c.iolFamilies || []).length && (c.iolFamilies || []).some(f => lensFamilies.includes(f)) && (!proc || (c.procedures || []).includes(proc.id) || !(c.procedures || []).length));
  if (famCards.length) card = famCards[0];
  else if (proc && proc.defaultCardId) card = prefcards.find(c => c.id === proc.defaultCardId) || null;
  if (!card && proc) card = prefcards.find(c => c.active !== false && (c.procedures || []).includes(proc.id)) || null;
  if (card) {
    // Both eyes today: a card built for "Both eyes" (Heather's Bilateral cards) already covers both; a
    // single-eye card is applied per eye, except items flagged "skip on same-day second eye".
    const cardIsBilateral = !!(proc && proc.eye === 'OU');
    const secondEyeSameDay = !!fellow && !cardIsBilateral;
    const differentDaySecondEye = !fellow && !!patient.secondEye;
    for (const it of (card.items || [])) {
      const p = byId[it.productId]; if (!p) continue;
      let qty = Number(it.qty) || 1;
      if (secondEyeSameDay && !it.skipSameDaySecondEye) qty = qty * 2; // one per eye
      if (differentDaySecondEye && it.skipDifferentDaySecondEye) continue;
      items.push({ key: `card|${p.id}`, source: 'card', eye: '', productId: p.id, description: p.description, qty, uom: it.uom || p.dispenseUom, lot: '', serial: '', expiry: '', unitCost: invLotUnitCost(p, null), note: it.note || '' });
    }
  }

  // 3. IOL-family extras (cartridge, peel packs, ...) per lens
  for (const fam of lensFamilies) for (const a of ((settings.iolAssociations || {})[fam] || [])) {
    const p = byId[a.productId]; if (!p) continue;
    const existing = items.find(i => i.key === `family|${p.id}`);
    if (existing) existing.qty += Number(a.qty) || 1;
    else items.push({ key: `family|${p.id}`, source: 'family', eye: '', productId: p.id, description: p.description, qty: Number(a.qty) || 1, uom: p.dispenseUom, lot: '', serial: '', expiry: '', unitCost: invLotUnitCost(p, null), note: `with ${fam} lens` });
  }

  // 4. charted meds / equipment (per eye), and pre-op sedation tablets
  const cfg = (procedures.config || {})[procKey] || { meds: [], equipment: [], dischargeMeds: [] };
  for (const rec of records) {
    for (const eye of eyesOf(rec)) {
      const eyeD = (rec.intraop && rec.intraop.eyes && rec.intraop.eyes[eye]) || {};
      const given = eyeD.meds || {};
      const surgeryStarted = Object.values(given).some(m => m && m.administered) || !!eyeD.surgeryStart;
      for (const m of (cfg.meds || [])) {
        if (!m.pullsToStock || !m.productId) continue;
        const p = byId[m.productId]; if (!p) continue;
        const id = dispMedId(m.name);
        const administered = !!(given[id] && given[id].administered);
        // Before surgery starts, the standard pull-to-stock meds are expected; once anything is charted, only what was actually given.
        const include = surgeryStarted ? administered : !!m.standard;
        if (!include) continue;
        items.push({ key: `med|${p.id}|${eye}`, source: 'chart', eye, productId: p.id, description: p.description, qty: 1, uom: p.dispenseUom, lot: '', serial: '', expiry: '', unitCost: invLotUnitCost(p, null), note: administered ? `given ${given[id].time || ''}`.trim() : 'expected (standard)' });
      }
      for (const am of (eyeD.additionalMeds || [])) {
        const cm = (cfg.meds || []).find(m => m.productId && invNorm(m.name) === invNorm(am.name)); const p = cm ? byId[cm.productId] : null;
        if (p) items.push({ key: `med|${p.id}|${eye}|extra`, source: 'chart', eye, productId: p.id, description: p.description, qty: 1, uom: p.dispenseUom, lot: '', serial: '', expiry: '', unitCost: invLotUnitCost(p, null), note: 'additional med' });
      }
      for (const eq of (eyeD.equipment || [])) {
        const ce = (cfg.equipment || []).find(e => e.productId && invNorm(e.name) === invNorm(eq)); const p = ce ? byId[ce.productId] : null;
        if (p) items.push({ key: `equip|${p.id}|${eye}`, source: 'chart', eye, productId: p.id, description: p.description, qty: 1, uom: p.dispenseUom, lot: '', serial: '', expiry: '', unitCost: invLotUnitCost(p, null), note: eq });
      }
    }
    // pre-op sedation → tablets (Xanax / Valium doses in mg ÷ tablet strength, rounded up)
    for (const pm of ((rec.preop && rec.preop.meds) || [])) {
      if (!pm.hasDose || !(Number(pm.dose) > 0) || !(pm.stamps || []).some(Boolean)) continue;
      const link = [...(cfg.dischargeMeds || []), ...(cfg.meds || [])].find(m => m.productId && invNorm(m.name).includes(invNorm(pm.name).split(' ')[0]));
      const p = link ? byId[link.productId] : products.find(x => x.active !== false && invNorm(x.description).includes(invNorm(pm.name).split(' ')[0]) && /tab/i.test(x.dispenseUom + ' ' + x.description));
      if (!p) continue;
      const sm = String(p.description).match(/(\d+(?:\.\d+)?)\s*mg/i); const strength = sm ? Number(sm[1]) : null;
      const tabs = strength ? Math.ceil(Number(pm.dose) / strength - 1e-9) : 1;
      const key = `sedation|${p.id}`; const ex = items.find(i => i.key === key);
      if (ex) ex.qty += tabs; else items.push({ key, source: 'chart', eye: '', productId: p.id, description: p.description, qty: tabs, uom: p.dispenseUom, lot: '', serial: '', expiry: '', unitCost: invLotUnitCost(p, null), note: `${pm.name} ${pm.dose} mg` });
    }
  }
  return { cardId: card ? card.id : null, cardName: card ? card.name : null, procedureKey: procKey, procedureId: proc ? proc.id : null, cancelled, items, notes };
}
const dispIsoDate = (v) => { const s = String(v || ''); if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; const m = s.match(/^(\d{2})\/(\d{4})$/); if (m) return `${m[2]}-${m[1]}-01`; return ''; };

// Merge freshly computed auto items into an existing dispensary, keeping the human's edits.
function dispReconcile(existing, auto, who) {
  const prev = existing || { version: 1, items: [], log: [], status: 'reserved' };
  const prevByKey = Object.fromEntries((prev.items || []).map(i => [i.key, i]));
  const now = new Date().toISOString();
  const log = [...(prev.log || [])];
  const note = (text) => log.push({ at: now, by: who || '', text });
  const items = [];
  for (const a of auto.items) {
    const e = prevByKey[a.key];
    if (e) {
      const merged = { ...a, removed: !!e.removed, userQty: e.userQty, qty: e.userQty != null ? e.userQty : a.qty, lot: a.lot || e.lot || '', serial: a.serial || e.serial || '', expiry: a.expiry || e.expiry || '' };
      // A lens assigned in the panel (scanned there) stays unless the chart itself scanned a box.
      if (a.source === 'lens' && e.userProductId && !a.fromChartScan) { merged.productId = e.userProductId; merged.description = e.description; merged.needsProduct = false; merged.unitCost = e.unitCost; merged.userProductId = e.userProductId; merged.family = e.family || null; }
      if (e.productId !== a.productId && a.source === 'lens') note(`Lens ${a.eye}: ${e.description} → ${a.description}`);
      else if (e.qty !== merged.qty && e.userQty == null) note(`${a.description}: ${e.qty} → ${merged.qty}`);
      items.push(merged);
    } else { if (prev.items && prev.items.length) note(`Added ${a.description}${a.eye ? ' (' + a.eye + ')' : ''}: ${a.note || a.source}`); items.push({ ...a, removed: false }); }
  }
  for (const e of (prev.items || [])) {
    if (e.source === 'manual' || e.source === 'scan') { items.push(e); continue; }
    if (!auto.items.some(a => a.key === e.key)) note(`Removed ${e.description}${e.eye ? ' (' + e.eye + ')' : ''}: no longer on the chart`);
  }
  if (prev.cardId && auto.cardId !== prev.cardId) note(`Preference card: ${prev.cardName || '—'} → ${auto.cardName || '—'}`);
  if (!prev.cancelled && auto.cancelled) note('Case cancelled — everything released');
  if (prev.cancelled && !auto.cancelled) note('Case un-cancelled — reserved again');
  const next = { ...prev, version: 1, cardId: auto.cardId, cardName: auto.cardName, procedureKey: auto.procedureKey, cancelled: auto.cancelled, items, log: log.slice(-60), builtAt: prev.builtAt || now, updatedAt: now };
  next.changed = JSON.stringify({ i: next.items, c: next.cardId, x: next.cancelled }) !== JSON.stringify({ i: prev.items || [], c: prev.cardId, x: prev.cancelled });
  return next;
}
// What the ledger should hold in reservation for this dispensary.
function dispDesired(disp) {
  if (!disp || disp.cancelled || disp.status === 'final') return [];
  return (disp.items || []).filter(i => !i.removed && i.productId && Number(i.qty) > 0).map(i => ({ key: i.key, productId: i.productId, qty: Number(i.qty), eye: i.eye || '' }));
}

// ─── Talking to the server about a case ────────────────────────
const DispApi = {
  async post(path, body) { const r = await fetch(path, { method: 'POST', headers: InvApi.headers(), body: JSON.stringify(body) }); const d = await r.json().catch(() => ({})); if (!r.ok) throw new Error(d.error || `Request failed (${r.status})`); return d; },
  async state(refId) { const r = await fetch(`/api/inventory/case/${encodeURIComponent(refId)}`, { headers: InvApi.headers() }); return r.json(); },
  sync(patient, disp) { return this.post('/api/inventory/case-sync', { refId: patient.id, patientId: patient.id, date: patient.dos, desired: dispDesired(disp), note: `Dispensary for ${patient.dos || 'case'}` }); },
  finalize(patient, disp) {
    const items = (disp.items || []).filter(i => !i.removed && i.productId && Number(i.qty) > 0).map(i => ({ key: i.key, productId: i.productId, qty: Number(i.qty), eye: i.eye || '', lot: i.lot || '', serial: i.serial || '', expiry: i.expiry || '', unitCost: i.unitCost, note: i.source === 'lens' ? 'Implanted' : (i.note || 'Used for case') }));
    return this.post('/api/inventory/case-finalize', { refId: patient.id, patientId: patient.id, date: patient.dos, items });
  },
  unlock(patient) { return this.post('/api/inventory/case-unlock', { refId: patient.id }); },
};

// Debounced reservation sync per case, fire-and-forget (the server computes the delta).
const dispSyncTimers = new Map();
function dispScheduleSync(patient, disp) {
  if (!patient || !disp || disp.status === 'final') return;
  clearTimeout(dispSyncTimers.get(patient.id));
  dispSyncTimers.set(patient.id, setTimeout(() => { DispApi.sync(patient, disp).then(() => { if (window.__invBadgeRefresh) window.__invBadgeRefresh(); }).catch(e => console.warn('Dispensary sync:', e.message)); }, 1500));
}

// ─── The glue the main app calls ───────────────────────────────
const SurgSuiteDispensary = {
  refs: DispRefs,
  medListFor: dispMedListFor, equipmentListFor: dispEquipmentListFor, standardEquipmentFor: dispStandardEquipmentFor, procKey: dispProcKey, matchLens: dispMatchLens, autoItems: dispAutoItems, reconcile: dispReconcile, desired: dispDesired,
  _setPatients: null,
  init({ setPatients, user }) { this._setPatients = setPatients; this._user = user || ''; DispRefs.load(); },
  who() { return this._user || ''; },

  // Which record holds the dispensary for this patient (the first eye of a same-day pair).
  primaryOf(patient, all) {
    if (!patient) return null;
    if (patient.bilateralPairId) {
      const fellow = (all || []).find(p => p.id === patient.bilateralPairId);
      if (fellow) { const prim = (patient.bilateralOrder === 2 || (fellow.bilateralOrder === 1 && patient.bilateralOrder !== 1)) ? fellow : patient; return { primary: prim, fellow: prim === patient ? fellow : patient }; }
    }
    return { primary: patient, fellow: null };
  },

  // Build (or rebuild) dispensaries for a list of patients, reserving stock. Used at worklist import.
  async buildForPatients(pts) {
    const refs = await DispRefs.load(true); if (!refs) return pts;
    const now = new Date().toISOString(), who = this.who();
    const out = pts.map(p => ({ ...p }));
    const done = new Set();
    for (const p of out) {
      if (done.has(p.id)) continue;
      const { primary, fellow } = this.primaryOf(p, out);
      const auto = dispAutoItems({ patient: primary, fellow, refs });
      const disp = dispReconcile(primary.dispensary && !primary.dispensary.sharedWith ? primary.dispensary : null, auto, who);
      delete disp.changed;
      primary.dispensary = disp; primary._sec = { ...(primary._sec || {}), dispensary: now }; primary._ts = now;
      if (fellow) { fellow.dispensary = { version: 1, sharedWith: primary.id }; fellow._sec = { ...(fellow._sec || {}), dispensary: now }; fellow._ts = now; done.add(fellow.id); }
      done.add(primary.id);
      try { await DispApi.sync(primary, disp); } catch (e) { console.warn('Dispensary reserve:', e.message); }
    }
    if (window.__invBadgeRefresh) window.__invBadgeRefresh();
    return out;
  },

  // Called from updatePatient with the new patient array: re-sync the dispensary of the changed record's case.
  applyChartChange(pts, changedId, now) {
    const refs = DispRefs.get(); if (!refs) return pts;
    const changed = pts.find(p => p.id === changedId); if (!changed) return pts;
    const { primary, fellow } = this.primaryOf(changed, pts);
    if (!primary.dispensary || primary.dispensary.sharedWith) return pts;           // not built yet (panel builds on open)
    if (primary.dispensary.status === 'final') return pts;                          // frozen until unlocked
    const auto = dispAutoItems({ patient: primary, fellow, refs });
    const next = dispReconcile(primary.dispensary, auto, this.who());
    if (!next.changed) return pts;
    delete next.changed;
    const stamped = { ...primary, dispensary: next, _sec: { ...(primary._sec || {}), dispensary: now }, _ts: now };
    dispScheduleSync(stamped, next);
    return pts.map(p => p.id === primary.id ? stamped : p);
  },

  // Called when a case is marked Complete. Idempotent on the server.
  async finalizeCase(patient, all) {
    const { primary } = this.primaryOf(patient, all || []);
    const disp = primary.dispensary; if (!disp || disp.sharedWith || disp.status === 'final' || disp.cancelled) return;
    try {
      const r = await DispApi.finalize(primary, disp);
      const now = new Date().toISOString();
      const lines = r.dispensedLines || [];
      const items = (disp.items || []).map(i => { const mine = lines.filter(l => l.itemKey === i.key); return mine.length ? { ...i, dispensed: mine.map(l => ({ qty: l.qty, lot: l.lot, serial: l.serial, expiry: l.expiry, unitCost: l.unitCost })) } : i; });
      const final = { ...disp, items, status: 'final', finalizedAt: now, finalizedBy: this.who(), log: [...(disp.log || []), { at: now, by: this.who(), text: r.alreadyFinal ? 'Already finalized on another device' : `Finalized — ${lines.length} stock line(s) written` }] };
      if (this._setPatients) this._setPatients(pts => pts.map(p => p.id === primary.id ? { ...p, dispensary: final, _sec: { ...(p._sec || {}), dispensary: now }, _ts: now } : p));
      if (window.__invBadgeRefresh) window.__invBadgeRefresh();
      return final;
    } catch (e) { console.warn('Dispensary finalize:', e.message); if (window.SSDiag && SSDiag.notSaved) try { SSDiag.notSaved('Supplies were not deducted from stock: ' + e.message); } catch {} }
  },

  async unlockCase(patient, all) {
    const { primary } = this.primaryOf(patient, all || []);
    const disp = primary.dispensary; if (!disp || disp.status !== 'final') return;
    await DispApi.unlock(primary);
    const now = new Date().toISOString();
    const reopened = { ...disp, status: 'reserved', finalizedAt: null, items: disp.items.map(i => { const { dispensed, ...rest } = i; return rest; }), log: [...(disp.log || []), { at: now, by: this.who(), text: 'Unlocked for correction — stock restored, reserved again' }] };
    await DispApi.sync(primary, reopened);
    if (this._setPatients) this._setPatients(pts => pts.map(p => p.id === primary.id ? { ...p, dispensary: reopened, _sec: { ...(p._sec || {}), dispensary: now }, _ts: now } : p));
    if (window.__invBadgeRefresh) window.__invBadgeRefresh();
    return reopened;
  },

  // The "Supplies & Implants" page of the operative PDF.
  pdfPage(doc, patient, h, all) {
    const { primary } = this.primaryOf(patient, all || []);
    const disp = primary.dispensary; if (!disp || disp.sharedWith) return;
    doc.addPage(); h.setY(h.YTOP);
    h.sec('Supplies & Implants' + (disp.status === 'final' ? ' (final)' : ' (as reserved — case not yet closed)'));
    let y = h.getY();
    doc.setFontSize(8); doc.setTextColor(90);
    doc.text(`Preference card: ${disp.cardName || '—'}   ·   Items: ${(disp.items || []).filter(i => !i.removed).length}${disp.finalizedAt ? '   ·   Finalized ' + new Date(disp.finalizedAt).toLocaleString() : ''}`, h.M + 2, y); y += 6;
    const cols = [h.M + 2, h.M + 78, h.M + 96, h.M + 118, h.M + 160];
    doc.setFontSize(7); doc.setTextColor(120);
    ['ITEM', 'QTY', 'EYE', 'LOT / SERIAL', 'SOURCE'].forEach((t, i) => doc.text(t, cols[i], y)); y += 4;
    doc.setDrawColor(200); doc.line(h.M, y, h.M + h.CW, y); y += 3;
    doc.setTextColor(30);
    const rows = (disp.items || []).filter(i => !i.removed).sort((a, b) => (a.source === 'lens' ? 0 : 1) - (b.source === 'lens' ? 0 : 1));
    for (const it of rows) {
      if (y > h.H - 30) { doc.addPage(); y = h.YTOP; }
      const lots = it.dispensed && it.dispensed.length ? it.dispensed.map(d => (d.serial ? 'SN ' + d.serial : d.lot ? 'lot ' + d.lot : '—') + (d.qty !== it.qty ? ` ×${d.qty}` : '')).join(', ') : (it.serial ? 'SN ' + it.serial : it.lot ? 'lot ' + it.lot : '—');
      doc.setFontSize(8);
      doc.text(String(it.description || '').slice(0, 48), cols[0], y);
      doc.text(`${it.qty} ${it.uom || ''}`.trim(), cols[1], y);
      doc.text(it.eye || '', cols[2], y);
      doc.text(String(lots).slice(0, 26), cols[3], y);
      doc.text(it.source === 'lens' ? 'implant' : it.source === 'card' ? 'pref card' : it.source, cols[4], y);
      y += 5;
    }
    if (!rows.length) { doc.text('No supplies recorded.', h.M + 2, y); y += 5; }
    h.setY(y + 4);
  },
};
window.SurgSuiteDispensary = SurgSuiteDispensary;

// ─── UI: the top-bar button and the slide-over panel ───────────
function DispensaryButton({ patient, all, onOpen }) {
  const { primary } = SurgSuiteDispensary.primaryOf(patient, all);
  const d = primary && primary.dispensary && !primary.dispensary.sharedWith ? primary.dispensary : null;
  const n = d ? d.items.filter(i => !i.removed).length : 0;
  const flag = d && d.items.some(i => !i.removed && i.needsProduct);
  return (
    <button className={`btn btn-sm ${d && d.status === 'final' ? 'btn-outline' : 'btn-teal'}`} onClick={onOpen} title="Supplies, drugs, and lens for this case" style={{ whiteSpace: 'nowrap' }}>
      📦 Inventory Management{n ? <span className="nav-badge" style={{ marginLeft: 6, background: flag ? 'var(--amber)' : undefined }}>{n}</span> : null}{d && d.status === 'final' ? ' ✓' : ''}
    </button>
  );
}

function DispensaryPanel({ patient, all, onClose, onUpdatePatient, onToast }) {
  const { primary, fellow } = SurgSuiteDispensary.primaryOf(patient, all);
  const [refs, setRefs] = invUseState(DispRefs.get());
  const [onHand, setOnHand] = invUseState(null);
  const [busy, setBusy] = invUseState(null);
  const [scanMsg, setScanMsg] = invUseState('');
  invUseEffect(() => { DispRefs.load().then(setRefs); fetch('/api/inventory/onhand', { headers: InvApi.headers() }).then(r => r.json()).then(setOnHand).catch(() => {}); }, []);
  const disp = primary.dispensary && !primary.dispensary.sharedWith ? primary.dispensary : null;

  // Build on first open if the chart has none yet (manual adds, reopened cases, older charts).
  invUseEffect(() => {
    if (!refs || disp) return;
    const auto = dispAutoItems({ patient: primary, fellow, refs });
    const built = dispReconcile(null, auto, SurgSuiteDispensary.who()); delete built.changed;
    const now = new Date().toISOString();
    onUpdatePatient({ ...primary, dispensary: built, _sec: { ...(primary._sec || {}), dispensary: now }, _ts: now });
    if (fellow) onUpdatePatient({ ...fellow, dispensary: { version: 1, sharedWith: primary.id }, _sec: { ...(fellow._sec || {}), dispensary: now }, _ts: now });
    DispApi.sync(primary, built).catch(() => {});
  }, [refs, !!disp]);

  const save = (next, logText) => {
    const now = new Date().toISOString();
    const withLog = logText ? { ...next, log: [...(next.log || []), { at: now, by: SurgSuiteDispensary.who(), text: logText }].slice(-60) } : next;
    onUpdatePatient({ ...primary, dispensary: withLog, _sec: { ...(primary._sec || {}), dispensary: now }, _ts: now });
    dispScheduleSync(primary, withLog);
  };
  const setItem = (key, patch, logText) => save({ ...disp, items: disp.items.map(i => i.key === key ? { ...i, ...patch } : i) }, logText);
  const addProduct = (p, scan) => {
    if (!disp) return;
    // A scanned lens box attaches to the lens row for the active eye; anything else becomes a new row.
    if (p.isIol) {
      const lensRow = disp.items.find(i => i.source === 'lens' && !i.removed && (i.eye === (patient.eye || 'OD'))) || disp.items.find(i => i.source === 'lens' && !i.removed);
      if (lensRow) {
        const fam = invIolFamilyOf(p, ((refs && refs.settings) || {}).iolFamilyRules || {});
        const nextDisp = { ...disp, items: disp.items.map(i => i.key === lensRow.key ? { ...i, productId: p.id, userProductId: p.id, description: p.description, needsProduct: false, family: fam || null, serial: (scan && scan.serial) || i.serial, lot: (scan && scan.lot) || i.lot, expiry: (scan && scan.expiry) || i.expiry, unitCost: invLotUnitCost(p, null) } : i) };
        // The scanned box also goes onto the chart's Intra-Op implant fields, so the operative record and the Dispensary agree.
        const owner = (fellow && lensRow.eye && fellow.eye === lensRow.eye) ? fellow : primary;
        const eye = lensRow.eye || owner.eye || 'OD';
        const io = owner.intraop || {}; const eyes = { ...(io.eyes || {}) }; const eyeD = { ...(eyes[eye] || {}) };
        if (scan && (scan.gtin || scan.serial)) {
          Object.assign(eyeD, { implantGtin: scan.gtin || eyeD.implantGtin || p.upc || '', implantSerial: scan.serial || eyeD.implantSerial || '', implantLot: scan.lot || eyeD.implantLot || '', implantExpiry: scan.expiry ? scan.expiry.slice(5, 7) + '/' + scan.expiry.slice(0, 4) : (eyeD.implantExpiry || ''), implantScannedModel: p.iolModel || p.description, implantScannedPower: p.iolPower || '', implantScanSource: 'barcode', implantScanRaw: scan.raw || '' });
          eyes[eye] = eyeD;
        }
        const now = new Date().toISOString();
        const logged = { ...nextDisp, log: [...(nextDisp.log || []), { at: now, by: SurgSuiteDispensary.who(), text: `Lens ${lensRow.eye}: ${p.description}${scan && scan.serial ? ' SN ' + scan.serial : ''}` }].slice(-60) };
        if (owner === primary) onUpdatePatient({ ...primary, intraop: { ...io, eyes }, dispensary: logged, _sec: { ...(primary._sec || {}), intraop: now, dispensary: now }, _ts: now });
        else { onUpdatePatient({ ...primary, dispensary: logged, _sec: { ...(primary._sec || {}), dispensary: now }, _ts: now }); onUpdatePatient({ ...fellow, intraop: { ...io, eyes }, _sec: { ...(fellow._sec || {}), intraop: now }, _ts: now }); }
        dispScheduleSync(primary, logged);
        return;
      }
    }
    const key = `scan|${p.id}|${(scan && scan.serial) || ''}`;
    const ex = disp.items.find(i => i.key === key);
    if (ex && !(scan && scan.serial)) { setItem(key, { qty: Number(ex.qty) + 1, removed: false }, `${p.description}: +1`); return; }
    save({ ...disp, items: [...disp.items, { key, source: 'scan', eye: patient.eye || '', productId: p.id, description: p.description, qty: 1, uom: p.dispenseUom, lot: (scan && scan.lot) || '', serial: (scan && scan.serial) || '', expiry: (scan && scan.expiry) || '', unitCost: invLotUnitCost(p, null), note: 'scanned / added' }] }, `Added ${p.description}`);
  };
  const finalize = async () => { setBusy('Finalizing…'); await SurgSuiteDispensary.finalizeCase(patient, all); onToast && onToast('Supplies deducted from stock'); setBusy(null); };
  const unlock = async () => { if (!window.confirm('Put these items back into stock and reopen the list for correction?')) return; setBusy('Unlocking…'); try { await SurgSuiteDispensary.unlockCase(patient, all); onToast && onToast('Dispensary unlocked'); } catch (e) { onToast && onToast('⚠️ ' + e.message, 'error'); } setBusy(null); };

  const avail = (i) => { if (!onHand || !i.productId) return null; const b = onHand.products[i.productId]; return b ? b.available : 0; };
  const groups = [['lens', 'Lens / implant'], ['card', 'Preference card'], ['family', 'Lens-family extras'], ['chart', 'From the chart'], ['scan', 'Scanned / added'], ['manual', 'Added by hand']];
  const isFinal = disp && disp.status === 'final';
  const row = (i) => {
    const a = avail(i); const short = a != null && !isFinal && !i.removed && a < Number(i.qty);
    return (
      <div key={i.key} style={{ display: 'grid', gridTemplateColumns: '1fr 74px 34px', gap: 6, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--gray-100)', opacity: i.removed ? 0.45 : 1 }}>
        <div style={{ minWidth: 0 }}>
          <div style={{ fontSize: '0.84rem', fontWeight: 600, textDecoration: i.removed ? 'line-through' : 'none' }}>{i.description}{i.eye ? <InvTag color="var(--teal)">{i.eye}</InvTag> : null}{i.needsProduct ? <InvTag color="var(--amber)">needs product — scan the box</InvTag> : null}{short ? <InvTag color="var(--red)">only {invFmtQty(a)} in stock</InvTag> : null}</div>
          <div style={{ fontSize: '0.72rem', color: 'var(--gray-500)' }}>
            {i.dispensed && i.dispensed.length ? i.dispensed.map(d => (d.serial ? 'SN ' + d.serial : d.lot ? 'lot ' + d.lot : 'no lot') + (d.expiry ? ' exp ' + d.expiry : '')).join(' · ')
              : (i.source === 'lens' || i.serial || i.lot) ? <>{i.serial ? 'SN ' + i.serial : <span style={{ color: 'var(--amber)' }}>serial not scanned yet</span>}{i.lot ? ' · lot ' + i.lot : ''}{i.expiry ? ' · exp ' + i.expiry : ''}</> : null}
            {i.note ? <span> · {i.note}</span> : null}
          </div>
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
          {isFinal || i.removed ? <span style={{ fontSize: '0.84rem', fontWeight: 600, textAlign: 'right', width: '100%' }}>{i.qty} {i.uom}</span>
            : <><input type="number" min="0" step="1" value={i.qty} onChange={e => setItem(i.key, { qty: Number(e.target.value), userQty: Number(e.target.value) }, `${i.description}: qty ${e.target.value}`)} style={{ padding: '4px 6px', width: 50 }} /><span style={{ fontSize: '0.7rem', color: 'var(--gray-500)' }}>{i.uom}</span></>}
        </div>
        <div>{!isFinal && (i.removed ? <button className="btn btn-outline btn-sm" style={{ padding: '2px 6px' }} title="Put back" onClick={() => setItem(i.key, { removed: false }, `Restored ${i.description}`)}>↺</button> : <button className="btn btn-outline btn-sm" style={{ padding: '2px 6px', color: 'var(--red)' }} title="Not used for this case" onClick={() => setItem(i.key, { removed: true }, `Removed ${i.description}`)}>✕</button>)}</div>
      </div>
    );
  };
  const total = disp ? disp.items.filter(i => !i.removed).reduce((n, i) => n + (Number(i.qty) || 0) * (Number(i.unitCost) || 0), 0) : 0;

  return (
    <div style={{ position: 'fixed', inset: 0, zIndex: 900, display: 'flex', justifyContent: 'flex-end' }} onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
      <div style={{ position: 'absolute', inset: 0, background: 'rgba(15,43,70,0.35)' }} onMouseDown={onClose} />
      <div style={{ position: 'relative', width: 'min(520px, 100vw)', height: '100%', background: 'white', boxShadow: '-12px 0 30px rgba(0,0,0,.2)', display: 'flex', flexDirection: 'column' }}>
        <div style={{ padding: '12px 16px', borderBottom: '1px solid var(--gray-200)', background: 'var(--gray-50)', display: 'flex', alignItems: 'center', gap: 8 }}>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontWeight: 700, color: 'var(--navy)' }}>📦 Dispensary — {primary.name}{fellow ? ' (both eyes)' : patient.eye ? ' ' + patient.eye : ''}</div>
            <div style={{ fontSize: '0.74rem', color: 'var(--gray-500)' }}>{disp ? <>{disp.cardName || 'No preference card matched'} · {disp.status === 'final' ? '✅ finalized — deducted from stock' : disp.cancelled ? 'cancelled — nothing reserved' : 'reserved in the Stock Room'}</> : 'Building…'}</div>
          </div>
          <button className="btn btn-outline btn-sm" onClick={onClose}>✕</button>
        </div>
        {busy && <div style={{ padding: '6px 16px', background: 'var(--gray-100)', color: 'var(--teal)', fontWeight: 600, fontSize: '0.82rem' }}>{busy}</div>}
        {!isFinal && disp && <div style={{ padding: '10px 16px', borderBottom: '1px solid var(--gray-200)' }}>
          <InvProductPicker products={(refs && refs.products) || []} onPick={(p, scan) => { setScanMsg(''); addProduct(p, scan); }} placeholder="Scan a box to add it (or attach a lens serial), or type to search…" />
          {scanMsg && <div style={{ fontSize: '0.76rem', color: 'var(--amber)', marginTop: 4 }}>{scanMsg}</div>}
        </div>}
        <div style={{ flex: 1, overflow: 'auto', padding: '8px 16px' }}>
          {!disp ? <div style={{ color: 'var(--gray-500)', padding: 20 }}>Building the list from the worklist and the preference card…</div> : (
            <>
              {groups.map(([src, label]) => { const its = disp.items.filter(i => i.source === src); if (!its.length) return null; return (
                <div key={src} style={{ marginBottom: 12 }}>
                  <div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--gray-500)', textTransform: 'uppercase', letterSpacing: '0.04em', margin: '6px 0 2px' }}>{label} · {its.filter(i => !i.removed).length}</div>
                  {its.map(row)}
                </div>); })}
              {disp.items.length === 0 && <div style={{ color: 'var(--gray-500)', fontSize: '0.84rem', padding: 12 }}>Nothing matched this case yet. Check the procedure name and the preference cards in the Stock Room, or scan items in.</div>}
              {disp.log && disp.log.length > 0 && <details style={{ marginTop: 8 }}><summary style={{ fontSize: '0.76rem', color: 'var(--gray-500)', cursor: 'pointer' }}>Change log ({disp.log.length})</summary>
                {disp.log.slice().reverse().map((l, i) => <div key={i} style={{ fontSize: '0.72rem', color: 'var(--gray-600)', padding: '2px 0' }}><span style={{ color: 'var(--gray-400)' }}>{new Date(l.at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span> {l.by ? l.by + ': ' : ''}{l.text}</div>)}</details>}
            </>
          )}
        </div>
        {disp && <div style={{ padding: '10px 16px', borderTop: '1px solid var(--gray-200)', background: 'var(--gray-50)', display: 'flex', alignItems: 'center', gap: 8 }}>
          <div style={{ flex: 1, fontSize: '0.78rem', color: 'var(--gray-600)' }}>{disp.items.filter(i => !i.removed).length} items · est. supply cost {invMoney(total)}</div>
          {isFinal ? <button className="btn btn-outline btn-sm" disabled={!!busy} onClick={unlock}>Unlock to correct</button>
            : <button className="btn btn-primary btn-sm" disabled={!!busy || disp.cancelled} onClick={finalize} title="Also happens automatically when the case is marked Complete">Deduct from stock now</button>}
        </div>}
      </div>
    </div>
  );
}
Object.assign(window.SurgSuiteDispensary, { DispensaryButton, DispensaryPanel });
