/* ConductBiology Molecular: the candidate gate as a browser tool.
   Spec: docs/specs/2026-10-04-molecular-v1-spec.md. The engine (lib/molecular) does every
   calculation; this file only collects inputs, shows results, and exports the pack. */

const MOL_EXAMPLE_BASE = "/molecular/examples/";
const MOL_EXAMPLE_FILES = [
  { slot: "sampleSheet", name: "sample-sheet.csv" },
  { slot: "qpcr", name: "qpcr-plate-1.txt" },
  { slot: "qpcr", name: "qpcr-plate-2.txt" },
  { slot: "viability", name: "viability.csv" },
  { slot: "confirmation", name: "confirmation.csv" },
];
const MOL_TONE_CHIP = { ok: "chip-ok", warn: "chip-warn", block: "chip-block", info: "chip-info", neutral: "" };
const MOL_SLOTS = {
  sampleSheet: { title: "Sample sheet", hint: "One row per sample: sample, candidate, role, replicate, and dose if you tested more than one. Optional: without it, the tool proposes one from your qPCR sample names.", multiple: false },
  qpcr: { title: "qPCR exports", hint: "QuantStudio or Design & Analysis results, Bio-Rad CFX Cq results, or a CSV with sample, target, and Cq. One file per plate.", multiple: true },
  viability: { title: "Viability exports", hint: "A table with sample and signal (for example CellTiter-Glo luminescence), with blank wells if you have them. One file per plate.", multiple: true },
  confirmation: { title: "Confirmation assay", hint: "Optional. Candidate and percent reduction (or percent of control), for example from a Western blot or ELISA. Mark pending measurements as pending.", multiple: false },
};
const MOL_ACCEPT = ".csv,.tsv,.txt,.xlsx,.xls";

function molCall(engine, id) {
  return engine.CALLS.find((call) => call.id === id) || { id, label: id, tone: "neutral" };
}

function MolCallChip({ engine, call, small }) {
  const meta = molCall(engine, call);
  return (
    <span className={`chip ${MOL_TONE_CHIP[meta.tone] || ""}`} style={small ? { padding: "1px 7px", fontSize: 10.5 } : undefined}>
      {meta.label}
    </span>
  );
}

function molPct(value, { censored, digits = 0 } = {}) {
  if (value === null || value === undefined || Number.isNaN(value)) return null;
  return `${censored ? "≥ " : ""}${Number(value).toFixed(digits)}%`;
}

function molSummaryValue(evidence, key) {
  const axis = evidence?.[key];
  if (!axis || !axis.n) return { text: "missing", tone: "tool-warn" };
  const main = molPct(axis.mean, { censored: axis.censored });
  const sd = axis.n > 1 && axis.sd !== null && axis.sd !== undefined ? ` ± ${Number(axis.sd).toFixed(0)}` : "";
  const tone = axis.status === "fail" ? "tool-bad" : axis.status === "not_reached" ? "tool-muted" : "";
  return { text: `${main}${sd}`, sub: `n = ${axis.n}`, tone };
}

// Amber only when the missing confirmation is what decided the call.
function molConfirmationText(confirmation, call) {
  const deciding = call === "confirm_first";
  if (!confirmation) return { text: "not measured", tone: "tool-muted" };
  if (confirmation.status === "pending") return { text: "pending", tone: deciding ? "tool-warn" : "tool-muted" };
  if (confirmation.status === "not_measured") return { text: "not measured", tone: deciding ? "tool-warn" : "tool-muted" };
  const text = `${Number(confirmation.mean).toFixed(0)}% lower${confirmation.assay ? ` (${confirmation.assay})` : ""}`;
  return { text, tone: confirmation.outcome === "fail" ? "tool-bad" : "" };
}

// Run ids look like "qpcr:plate-1"; people read the plate name.
function molRunName(id) {
  return String(id || "").replace(/^(qpcr|viability):/, "");
}

function molUniqueName(name, taken) {
  const base = name.replace(/\.[^.]+$/, "");
  let candidate = base;
  let n = 2;
  while (taken.includes(candidate)) candidate = `${base} (${n++})`;
  return candidate;
}

function MolecularTool() {
  const engine = useToolEngine("CBMolecular");
  const savedId = toolSavedParam();
  if (!engine) {
    return <div className="page-wrap tool-body"><p className="tool-muted">Loading the Molecular engine…</p></div>;
  }
  if (savedId) return <MolSavedPack engine={engine} id={savedId} />;
  return <MolecularWorkspace engine={engine} />;
}

// /molecular/app?saved=<id>: a decision pack saved to the account, read-only.
function MolSavedPack({ engine, id }) {
  const saved = useSavedItem(id);
  const pack = saved.item && saved.item.payload ? saved.item.payload.pack : null;
  const base = toolSlug(saved.item ? saved.item.title : "", "decision-pack");
  return (
    <main>
      <div className="page-wrap">
        <div className="tool-head">
          <span className="mono" style={{ fontSize: 11, color: "var(--brand-700)", letterSpacing: ".08em", textTransform: "uppercase", fontWeight: 600 }}>ConductBiology Molecular</span>
          <h1>{saved.item ? saved.item.title : "Saved decision pack"}</h1>
          <p>A decision pack saved to your account. To check it against your files, run them again in the tool and use Replay.</p>
        </div>
      </div>
      <div className="page-wrap tool-body">
        {saved.status === "loading" ? <p className="tool-muted">Opening the saved decision pack…</p> : null}
        {saved.status === "error" ? (
          <ToolNotice tone="block" title="Could not open this decision pack">
            {saved.message} <a className="btn-link" href="/saved">Back to Saved work</a>
          </ToolNotice>
        ) : null}
        {saved.status === "ready" && !pack ? <ToolNotice tone="block" title="This saved item has no decision pack." /> : null}
        {saved.status === "ready" && pack ? (
          <>
            <div className="no-print"><ToolSavedBanner item={saved.item} module="molecular" /></div>
            <div className="tool-grid">
              <div className="tool-stack">
                <div className="print-root"><MolPackReport engine={engine} pack={pack} /></div>
              </div>
              <div className="tool-stack no-print" style={{ position: "sticky", top: 76 }}>
                <ToolPanel title="Export">
                  <div style={{ display: "grid", gap: 8 }}>
                    <button type="button" className="btn btn-primary btn-tap" onClick={() => window.print()}>Print or save as PDF</button>
                    <button type="button" className="btn btn-ghost btn-tap" onClick={() => toolDownload(`${base}.csv`, engine.packCsv(pack), "text/csv")}>Download CSV</button>
                    <button type="button" className="btn btn-ghost btn-tap" onClick={() => toolDownload(`${base}.json`, JSON.stringify(pack, null, 2), "application/json")}>Download JSON</button>
                    <button type="button" className="btn btn-ghost btn-tap" onClick={() => toolDownload(`${base}-methods.txt`, engine.methodsText(pack))}>Download methods text</button>
                  </div>
                </ToolPanel>
              </div>
            </div>
          </>
        ) : null}
      </div>
    </main>
  );
}

function MolecularWorkspace({ engine }) {
  const { useState, useMemo, useEffect } = React;
  const [step, setStep] = useState("criteria");
  const [study, setStudy] = useState({ name: "", description: "" });
  const [criteria, setCriteria] = useState(() => engine.defaultCriteria());
  const [lock, setLock] = useState(null);
  const [lockNote, setLockNote] = useState(null);
  const [exploratoryChosen, setExploratoryChosen] = useState(false);
  const [firstResultAt, setFirstResultAt] = useState(null);
  const [amendments, setAmendments] = useState([]);
  const [amending, setAmending] = useState(null);
  const [files, setFiles] = useState({ sampleSheet: null, qpcr: [], viability: [], confirmation: null });
  const [samples, setSamples] = useState(null);
  const [samplesSource, setSamplesSource] = useState(null);
  const [mappingConfirmed, setMappingConfirmed] = useState(false);
  const [exclusions, setExclusions] = useState([]);
  const [selected, setSelected] = useState(null);
  const [review, setReview] = useState({ reviewer: "", role: "", decision: null, comment: "" });
  const [pack, setPack] = useState(null);
  const [replay, setReplay] = useState(null);
  const [busy, setBusy] = useState(null);
  const [error, setError] = useState(null);
  const [resetArmed, setResetArmed] = useState(false);

  const validation = useMemo(() => engine.validateCriteria(criteria), [engine, criteria]);

  // A signed-in reviewer does not have to type their name on the sign-off.
  const account = useAccount();
  useEffect(() => {
    if (account && account.user) setReview((r) => (r.reviewer ? r : { ...r, reviewer: account.user.name }));
  }, [account]);

  // /molecular/app?example=1 opens straight into the example study.
  useEffect(() => {
    if (new URLSearchParams(window.location.search).get("example")) loadExample();
  }, []);
  const resultsLoaded = files.qpcr.length + files.viability.length + (files.confirmation ? 1 : 0) > 0;
  const mode = !lock || exploratoryChosen || amendments.some((a) => a.afterResults)
    || (firstResultAt && Date.parse(lock.lockedAt) > Date.parse(firstResultAt)) ? "exploratory" : "registered";

  // Propose a sample sheet from the qPCR sample names when none was loaded.
  useEffect(() => {
    if (files.sampleSheet || samplesSource === "edited") return;
    if (!files.qpcr.length) { if (samplesSource === "proposed") { setSamples(null); setSamplesSource(null); } return; }
    const wells = files.qpcr.flatMap((f) => f.parsed.wells || []);
    const proposal = engine.proposeSampleSheet(wells);
    setSamples(proposal.samples);
    setSamplesSource("proposed");
  }, [engine, files.qpcr, files.sampleSheet]);

  const mapping = useMemo(() => molMappingCheck(engine, criteria, files, samples), [engine, criteria, files, samples]);

  const result = useMemo(() => {
    if (!mappingConfirmed || !samples || !validation.ok) return null;
    try {
      return engine.analyze({
        criteria,
        samples,
        qpcr: files.qpcr.map((f) => f.parsed),
        viability: files.viability.map((f) => f.parsed),
        confirmation: files.confirmation ? files.confirmation.parsed : null,
        exclusions,
      });
    } catch (err) {
      return { error: err.message || String(err) };
    }
  }, [engine, mappingConfirmed, samples, criteria, files, exclusions, validation.ok]);

  const inputs = useInputsList(engine, files, samples, samplesSource);

  // The pack is rebuilt whenever anything it records changes.
  useEffect(() => {
    let cancelled = false;
    if (!result || result.error || !inputs) { setPack(null); return undefined; }
    (async () => {
      const criteriaHash = lock ? lock.criteriaHash : await engine.criteriaHash(criteria);
      const built = await engine.buildPack({
        study,
        criteria,
        lock: lock ? { lockedAt: lock.lockedAt, criteriaHash, source: lock.source } : null,
        mode,
        amendments,
        inputs,
        result,
        review: review.reviewer || review.decision || review.comment ? { ...review, date: new Date().toISOString().slice(0, 10) } : null,
        exportedAt: new Date().toISOString(),
      });
      if (!cancelled) setPack(built);
    })().catch((err) => { if (!cancelled) setError(err.message || String(err)); });
    return () => { cancelled = true; };
  }, [engine, result, inputs, lock, mode, amendments, study, criteria, review]);

  const steps = [
    { key: "criteria", label: "Criteria", done: Boolean(lock) || exploratoryChosen },
    { key: "files", label: "Files", done: mappingConfirmed, disabled: !lock && !exploratoryChosen },
    { key: "calls", label: "Calls", done: Boolean(result && !result.error), disabled: !result },
    { key: "pack", label: "Decision pack", done: Boolean(review.decision), disabled: !result || Boolean(result.error) },
  ];

  async function lockCriteria() {
    if (!validation.ok) return;
    const criteriaHash = await engine.criteriaHash(criteria);
    setLock({ lockedAt: new Date().toISOString(), criteriaHash, source: "session" });
    setLockNote(null);
    setExploratoryChosen(false);
    setStep("files");
  }

  async function downloadCriteria() {
    const file = await engine.lockCriteria(criteria, lock.lockedAt);
    toolDownload(`${toolSlug(study.name, "criteria")}-criteria.json`, JSON.stringify(file, null, 2), "application/json");
  }

  // Reads a criteria file with the engine, which only trusts a lock whose fingerprint
  // still matches the criteria.
  async function readCriteria(text) {
    const res = await engine.readCriteriaFile(text);
    if (!res.criteria) throw new Error(res.errors.join(" "));
    const check = engine.validateCriteria(res.criteria);
    if (!check.ok) throw new Error(`That criteria file is not valid: ${check.errors.map((e) => e.message).join(" ")}`);
    return res;
  }

  async function loadCriteriaFile(fileList) {
    setError(null);
    try {
      const read = await toolReadFile(fileList[0]);
      const res = await readCriteria(read.text);
      setCriteria(res.criteria);
      if (res.lock) {
        setLock({ ...res.lock, source: "file" });
        setLockNote({ tone: "ok", text: `Criteria file loaded. Locked ${toolFmtTime(res.lock.lockedAt)}; the fingerprint matches.` });
        setExploratoryChosen(false);
      } else {
        setLock(null);
        const changed = res.errors.some((e) => /changed after it was locked|does not match/.test(e));
        setLockNote({ tone: "warn", text: changed ? "The criteria in this file were changed after they were locked, so they are loaded unlocked." : "Criteria loaded. They are not locked yet." });
      }
    } catch (err) {
      setError(err.message || String(err));
    }
  }

  function startAmendment() { setAmending({ reason: "" }); }
  function confirmAmendment() {
    if (!amending.reason.trim()) return;
    setAmendments((list) => [...list, { at: new Date().toISOString(), reason: amending.reason.trim(), afterResults: resultsLoaded, previousHash: lock ? lock.criteriaHash : null }]);
    setLock(null);
    setAmending(null);
  }

  async function addFiles(slot, fileList) {
    setError(null);
    setBusy("Reading files…");
    try {
      const entries = [];
      const takenRuns = [...files.qpcr, ...files.viability].map((f) => f.runName);
      for (const file of fileList) {
        const read = await toolReadFile(file);
        const rows = read.rows || engine.readTable(read.text);
        const runName = molUniqueName(read.name, takenRuns.concat(entries.map((e) => e.runName)));
        let parsed;
        if (slot === "sampleSheet") parsed = engine.readSampleSheet(rows);
        else if (slot === "qpcr") parsed = engine.readQpcr(rows, { run: runName });
        else if (slot === "viability") parsed = engine.readViability(rows, { plate: runName });
        else parsed = engine.readConfirmation(rows);
        entries.push({ id: `${slot}:${read.sha256}:${read.name}`, slot, runName, name: read.name, size: read.size, sha256: read.sha256, kind: read.kind, sheetName: read.sheetName, parsed });
      }
      if (slot !== "sampleSheet" && !firstResultAt) setFirstResultAt(new Date().toISOString());
      setFiles((current) => {
        const next = { ...current };
        if (slot === "qpcr" || slot === "viability") {
          const known = new Set(current[slot].map((f) => f.sha256));
          next[slot] = [...current[slot], ...entries.filter((e) => !known.has(e.sha256))];
        } else next[slot] = entries[0];
        return next;
      });
      if (slot === "sampleSheet") {
        setSamples(entries[0].parsed.samples);
        setSamplesSource("file");
      }
      setMappingConfirmed(false);
      setReplay(null);
    } catch (err) {
      setError(err.message || String(err));
    } finally {
      setBusy(null);
    }
  }

  function removeFile(slot, id) {
    setFiles((current) => {
      const next = { ...current };
      if (Array.isArray(current[slot])) next[slot] = current[slot].filter((f) => f.id !== id);
      else next[slot] = null;
      return next;
    });
    if (slot === "sampleSheet") { setSamples(null); setSamplesSource(null); }
    setMappingConfirmed(false);
    setReplay(null);
  }

  function confirmMapping() {
    setMappingConfirmed(true);
    setSelected(null);
    setStep("calls");
  }

  async function loadExample() {
    setError(null);
    setBusy("Loading the example study…");
    try {
      const criteriaFile = await toolFetchAsFile(`${MOL_EXAMPLE_BASE}criteria.json`, "criteria.json");
      const res = await readCriteria(await criteriaFile.text());
      if (!res.lock) throw new Error("The example criteria file is not locked.");
      const fetched = await Promise.all(MOL_EXAMPLE_FILES.map(async (f) => ({ ...f, file: await toolFetchAsFile(MOL_EXAMPLE_BASE + f.name, f.name) })));
      const next = { sampleSheet: null, qpcr: [], viability: [], confirmation: null };
      let sheet = null;
      for (const item of fetched) {
        const read = await toolReadFile(item.file);
        const rows = engine.readTable(read.text);
        const runName = item.name.replace(/\.[^.]+$/, "");
        let parsed;
        if (item.slot === "sampleSheet") { parsed = engine.readSampleSheet(rows); sheet = parsed.samples; }
        else if (item.slot === "qpcr") parsed = engine.readQpcr(rows, { run: runName });
        else if (item.slot === "viability") parsed = engine.readViability(rows, { plate: runName });
        else parsed = engine.readConfirmation(rows);
        const entry = { id: `${item.slot}:${read.sha256}:${read.name}`, slot: item.slot, runName, name: read.name, size: read.size, sha256: read.sha256, kind: read.kind, parsed };
        if (Array.isArray(next[item.slot])) next[item.slot].push(entry); else next[item.slot] = entry;
      }
      setStudy({ name: "Example knockdown screen (made-up data)", description: "Seven ASO candidates against TARGET, 10 nM, three biological replicates. Every value is made up." });
      setCriteria(res.criteria);
      setLock({ ...res.lock, source: "file" });
      setLockNote({ tone: "ok", text: `Example criteria locked ${toolFmtTime(res.lock.lockedAt)}, before the results were loaded.` });
      setExploratoryChosen(false);
      setAmendments([]);
      setFirstResultAt(new Date().toISOString());
      setFiles(next);
      setSamples(sheet);
      setSamplesSource("file");
      setExclusions([]);
      setReview((r) => ({ reviewer: r.reviewer, role: "", decision: null, comment: "" }));
      setReplay(null);
      setMappingConfirmed(true);
      setSelected(null);
      setStep("calls");
    } catch (err) {
      setError(err.message || String(err));
    } finally {
      setBusy(null);
    }
  }

  function resetAll() {
    if (!resetArmed) { setResetArmed(true); setTimeout(() => setResetArmed(false), 4000); return; }
    setResetArmed(false);
    setStep("criteria"); setStudy({ name: "", description: "" }); setCriteria(engine.defaultCriteria());
    setLock(null); setLockNote(null); setExploratoryChosen(false); setFirstResultAt(null); setAmendments([]); setAmending(null);
    setFiles({ sampleSheet: null, qpcr: [], viability: [], confirmation: null }); setSamples(null); setSamplesSource(null);
    setMappingConfirmed(false); setExclusions([]); setSelected(null);
    setReview({ reviewer: account && account.user ? account.user.name : "", role: "", decision: null, comment: "" });
    setPack(null); setReplay(null); setError(null);
  }

  return (
    <main>
      <div className="page-wrap">
        <div className="tool-head">
          <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
            <span className="mono" style={{ fontSize: 11, color: "var(--brand-700)", letterSpacing: ".08em", textTransform: "uppercase", fontWeight: 600 }}>ConductBiology Molecular</span>
            <MolModeChip mode={mode} lock={lock} />
          </div>
          <h1>{study.name || "Candidate gate"}</h1>
          <p>Lock your criteria, load the screen, and get an advance, repeat, or stop call for every candidate, with the reason and the wording your data supports.</p>
          <div className="tool-actions no-print">
            <button type="button" className="btn btn-ghost btn-tap" onClick={loadExample} disabled={Boolean(busy)}>Load example study</button>
            <button type="button" className="btn btn-link btn-tap" onClick={resetAll}>{resetArmed ? "Click again to clear everything" : "Start over"}</button>
            {busy ? <span className="tool-muted" style={{ fontSize: 13 }}>{busy}</span> : null}
          </div>
          <ToolPrivacyLine />
        </div>
        <div className="no-print"><ToolSteps steps={steps} current={step} onSelect={setStep} /></div>
      </div>

      <div className="page-wrap tool-body">
        {error ? <div style={{ marginBottom: 16 }}><ToolNotice tone="block" title="Something needs attention">{error}</ToolNotice></div> : null}

        {step === "criteria" ? (
          <MolCriteriaStep engine={engine} study={study} setStudy={setStudy} criteria={criteria} setCriteria={setCriteria}
                           validation={validation} lock={lock} lockNote={lockNote} onLock={lockCriteria} onDownload={downloadCriteria}
                           onLoadFile={loadCriteriaFile} resultsLoaded={resultsLoaded} onUnlock={() => setLock(null)}
                           amending={amending} setAmending={setAmending} onStartAmend={startAmendment} onConfirmAmend={confirmAmendment}
                           amendments={amendments} exploratoryChosen={exploratoryChosen}
                           onExploratory={() => { setExploratoryChosen(true); setStep("files"); }} />
        ) : null}

        {step === "files" ? (
          <MolFilesStep engine={engine} files={files} onAdd={addFiles} onRemove={removeFile} samples={samples}
                        samplesSource={samplesSource} setSamples={(rows) => { setSamples(rows); setSamplesSource("edited"); setMappingConfirmed(false); }}
                        mapping={mapping} onConfirm={confirmMapping} mappingConfirmed={mappingConfirmed} busy={busy} criteria={criteria} />
        ) : null}

        {step === "calls" && result ? (
          result.error
            ? <ToolNotice tone="block" title="The analysis stopped">{result.error}</ToolNotice>
            : <MolCallsStep engine={engine} result={result} mode={mode} selected={selected} setSelected={setSelected}
                            exclusions={exclusions} setExclusions={setExclusions} onNext={() => setStep("pack")} />
        ) : null}

        {step === "pack" && result && !result.error ? (
          <MolPackStep engine={engine} pack={pack} review={review} setReview={setReview} study={study}
                       replay={replay} setReplay={setReplay} setError={setError} />
        ) : null}
      </div>
    </main>
  );
}

// Inputs recorded in the pack. A proposed sample sheet is recorded as the CSV the tool
// would download, so replaying with that downloaded sheet reproduces the fingerprint.
function useInputsList(engine, files, samples, samplesSource) {
  const [inputs, setInputs] = React.useState(null);
  React.useEffect(() => {
    let cancelled = false;
    (async () => {
      if (!samples) { setInputs(null); return; }
      const list = [];
      if (files.sampleSheet && samplesSource === "file") {
        list.push({ role: "sample_sheet", name: files.sampleSheet.name, size: files.sampleSheet.size, sha256: files.sampleSheet.sha256 });
      } else {
        const csv = engine.sampleSheetCsv(samples);
        list.push({ role: "sample_sheet", name: "sample-sheet (confirmed in the tool).csv", size: new TextEncoder().encode(csv).length, sha256: await window.CBHash.sha256Hex(csv) });
      }
      for (const f of files.qpcr) list.push({ role: "qpcr", name: f.name, size: f.size, sha256: f.sha256 });
      for (const f of files.viability) list.push({ role: "viability", name: f.name, size: f.size, sha256: f.sha256 });
      if (files.confirmation) list.push({ role: "confirmation", name: files.confirmation.name, size: files.confirmation.size, sha256: files.confirmation.sha256 });
      if (!cancelled) setInputs(list);
    })();
    return () => { cancelled = true; };
  }, [engine, files, samples, samplesSource]);
  return inputs;
}

function molMappingCheck(engine, criteria, files, samples) {
  const errors = [];
  const warnings = [];
  const notes = [];
  const fileProblems = [];
  for (const slot of ["sampleSheet", "qpcr", "viability", "confirmation"]) {
    const list = Array.isArray(files[slot]) ? files[slot] : files[slot] ? [files[slot]] : [];
    for (const f of list) {
      for (const message of f.parsed.errors || []) fileProblems.push(`${f.name}: ${message.message || message}`);
    }
  }
  errors.push(...fileProblems);
  if (!files.qpcr.length) errors.push("Add at least one qPCR export.");
  if (!files.viability.length) warnings.push("No viability export yet. Every candidate will get Not enough evidence until one is added.");
  if (!samples || !samples.length) errors.push("The sample sheet is empty.");

  const targets = new Map();
  for (const f of files.qpcr) for (const t of f.parsed.targets || []) targets.set(String(t).trim().toLowerCase(), t);
  const want = [engine.getPath(criteria, "targetGene"), ...(engine.getPath(criteria, "referenceGenes") || [])].filter(Boolean);
  for (const gene of want) {
    if (files.qpcr.length && !targets.has(String(gene).trim().toLowerCase())) {
      errors.push(`Gene "${gene}" from the criteria is not in the qPCR files. Found: ${[...targets.values()].join(", ") || "none"}.`);
    }
  }

  if (samples && files.qpcr.length) {
    const sheetNames = new Set(samples.map((s) => s.sample));
    const qpcrNames = new Set(files.qpcr.flatMap((f) => f.parsed.samples || []));
    const unknown = [...qpcrNames].filter((name) => name && !sheetNames.has(name));
    const absent = samples.filter((s) => s.role !== "blank" && s.role !== "ntc" && !qpcrNames.has(s.sample)).map((s) => s.sample);
    if (unknown.length) warnings.push(`${unknown.length} qPCR sample name${unknown.length === 1 ? " is" : "s are"} not in the sample sheet and will be ignored: ${unknown.slice(0, 8).join(", ")}${unknown.length > 8 ? "…" : ""}`);
    if (absent.length) warnings.push(`${absent.length} sample${absent.length === 1 ? " has" : "s have"} no qPCR wells: ${absent.slice(0, 8).join(", ")}${absent.length > 8 ? "…" : ""}`);
    const roles = new Set(samples.map((s) => s.role));
    const comparator = engine.getPath(criteria, "activity.comparator");
    if (comparator && !roles.has(comparator)) errors.push(`No sample has the role "${comparator}", which the criteria use as the knockdown comparator.`);
    const candidates = new Set(samples.filter((s) => s.role === "candidate").map((s) => s.candidate));
    notes.push(`${candidates.size} candidate${candidates.size === 1 ? "" : "s"}, ${samples.length} samples, ${files.qpcr.reduce((n, f) => n + (f.parsed.wells || []).length, 0)} qPCR wells.`);
  }
  for (const f of [...files.qpcr, ...files.viability]) for (const w of f.parsed.warnings || []) warnings.push(`${f.name}: ${w.message || w}`);
  if (files.confirmation) for (const w of files.confirmation.parsed.warnings || []) warnings.push(`${files.confirmation.name}: ${w.message || w}`);
  if (files.sampleSheet) for (const w of files.sampleSheet.parsed.warnings || []) warnings.push(`${files.sampleSheet.name}: ${w.message || w}`);
  return { errors, warnings, notes, ok: errors.length === 0 };
}

function MolModeChip({ mode, lock }) {
  if (mode === "registered") return <span className="chip chip-ok">Registered: criteria locked before results</span>;
  if (lock) return <span className="chip chip-warn">Exploratory</span>;
  return <span className="chip">Criteria not locked</span>;
}

/* Step 1 ------------------------------------------------------------------ */

function MolCriteriaStep(props) {
  const { engine, study, setStudy, criteria, setCriteria, validation, lock, lockNote, onLock, onDownload, onLoadFile,
          resultsLoaded, onUnlock, amending, setAmending, onStartAmend, onConfirmAmend, amendments, onExploratory, exploratoryChosen } = props;
  const fields = engine.criteriaFields();
  const groups = [];
  for (const field of fields) {
    let group = groups.find((g) => g.name === field.group);
    if (!group) { group = { name: field.group, fields: [] }; groups.push(group); }
    group.fields.push(field);
  }
  const errorFor = (path) => validation.errors.find((e) => e.path === path);
  const locked = Boolean(lock);

  return (
    <div className="tool-grid">
      <div className="tool-stack">
        <ToolPanel title="Study">
          <div className="tool-form-grid">
            <label className="tool-field">
              <span>Study name</span>
              <input className="tool-input" value={study.name} disabled={locked} placeholder="For example: TARGET ASO screen, round 2"
                     onChange={(e) => setStudy({ ...study, name: e.target.value })} />
            </label>
            <label className="tool-field">
              <span>Notes (optional)</span>
              <input className="tool-input" value={study.description} disabled={locked} placeholder="Cell line, dose, timepoint"
                     onChange={(e) => setStudy({ ...study, description: e.target.value })} />
            </label>
          </div>
        </ToolPanel>

        {groups.map((group) => (
          <ToolPanel key={group.name} title={group.name}>
            <div className="tool-form-grid">
              {group.fields.map((field) => (
                <MolCriterionField key={field.path} engine={engine} field={field} criteria={criteria} disabled={locked}
                                   error={errorFor(field.path)} onChange={(value) => setCriteria(engine.setPath(criteria, field.path, value))} />
              ))}
            </div>
          </ToolPanel>
        ))}
      </div>

      <div className="tool-stack" style={{ position: "sticky", top: 76 }}>
        <ToolPanel title="Lock the criteria">
          {locked ? (
            <div style={{ display: "grid", gap: 12 }}>
              <ToolNotice tone="ok" title={`Locked ${toolFmtTime(lock.lockedAt)}`}>
                Fingerprint <span className="tool-mono">{lock.criteriaHash.slice(0, 16)}…</span>
                {lock.source === "file" ? " (from a criteria file)" : null}
              </ToolNotice>
              <button type="button" className="btn btn-primary btn-tap" onClick={onDownload}>Download criteria file</button>
              <p className="tool-muted" style={{ fontSize: 13, margin: 0, lineHeight: 1.5 }}>
                Send the file or its fingerprint to your sponsor or study file before the screen. The decision pack records both.
              </p>
              {resultsLoaded ? (
                amending ? (
                  <div style={{ display: "grid", gap: 8 }}>
                    <label className="tool-field">
                      <span>Why are the criteria changing?</span>
                      <input className="tool-input" autoFocus value={amending.reason} onChange={(e) => setAmending({ reason: e.target.value })} />
                      <small>Results are already loaded, so the pack will be marked exploratory and list this change.</small>
                    </label>
                    <div style={{ display: "flex", gap: 8 }}>
                      <button type="button" className="btn btn-primary btn-tap" disabled={!amending.reason.trim()} onClick={onConfirmAmend}>Amend</button>
                      <button type="button" className="btn btn-ghost btn-tap" onClick={() => setAmending(null)}>Cancel</button>
                    </div>
                  </div>
                ) : <button type="button" className="btn btn-ghost btn-tap" onClick={onStartAmend}>Amend criteria</button>
              ) : <button type="button" className="btn btn-ghost btn-tap" onClick={onUnlock}>Unlock to edit</button>}
            </div>
          ) : (
            <div style={{ display: "grid", gap: 12 }}>
              <p style={{ margin: 0, fontSize: 14, lineHeight: 1.55, color: "var(--ink-700)" }}>
                Lock before you load results. The pack then says the criteria were set first, and a strong number in one assay cannot move a threshold.
              </p>
              {!validation.ok ? <ToolNotice tone="warn" title="Before locking" items={validation.errors.map((e) => e.message)} /> : null}
              <button type="button" className="btn btn-primary btn-tap" disabled={!validation.ok} onClick={onLock}>Lock criteria</button>
              <ToolDrop title="Load a criteria file" hint="A criteria file downloaded earlier from this tool." accept=".json,application/json" onFiles={onLoadFile} />
              <button type="button" className="btn btn-link btn-tap" style={{ justifySelf: "start" }} onClick={onExploratory}>
                {exploratoryChosen ? "Continue without locking" : "Analyze without locking (exploratory)"}
              </button>
            </div>
          )}
          {lockNote ? <div style={{ marginTop: 12 }}><ToolNotice tone={lockNote.tone}>{lockNote.text}</ToolNotice></div> : null}
          {amendments.length ? (
            <div style={{ marginTop: 12 }}>
              <ToolNotice tone="warn" title="Amendments" items={amendments.map((a) => `${toolFmtTime(a.at)}: ${a.reason}`)} />
            </div>
          ) : null}
        </ToolPanel>
        <p className="tool-muted" style={{ fontSize: 12.5, lineHeight: 1.5, margin: 0 }}>
          The defaults are a starting template. Set each value for your program.
        </p>
      </div>
    </div>
  );
}

function MolCriterionField({ engine, field, criteria, disabled, error, onChange }) {
  const value = engine.getPath(criteria, field.path);
  const id = `crit-${field.path.replace(/\./g, "-")}`;
  let control;
  if (field.type === "boolean") {
    return (
      <label className="tool-check" htmlFor={id} style={{ alignSelf: "end" }}>
        <input id={id} type="checkbox" checked={Boolean(value)} disabled={disabled} onChange={(e) => onChange(e.target.checked)} />
        <span>{field.label}{field.help ? <small style={{ display: "block", color: "var(--ink-500)", fontSize: 12 }}>{field.help}</small> : null}</span>
      </label>
    );
  }
  if (field.type === "select") {
    control = (
      <select id={id} className="tool-input" value={value ?? ""} disabled={disabled} onChange={(e) => onChange(e.target.value)}>
        {field.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
      </select>
    );
  } else if (field.type === "number") {
    control = (
      <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <input id={id} className="tool-input" type="number" inputMode="decimal" value={value ?? ""} min={field.min} max={field.max} step={field.step ?? "any"}
               disabled={disabled} style={{ maxWidth: 160 }}
               onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))} />
        {field.unit ? <span className="tool-muted" style={{ fontSize: 13 }}>{field.unit}</span> : null}
      </div>
    );
  } else if (field.type === "genes") {
    control = (
      <input id={id} className="tool-input" value={(value || []).join(", ")} disabled={disabled} placeholder="GAPDH, ACTB"
             onChange={(e) => onChange(e.target.value.split(/[,;]/).map((s) => s.trim()).filter(Boolean))} />
    );
  } else {
    control = <input id={id} className="tool-input" value={value ?? ""} disabled={disabled} onChange={(e) => onChange(e.target.value)} />;
  }
  return (
    <label className="tool-field" htmlFor={id}>
      <span>{field.label}</span>
      {control}
      {error ? <small style={{ color: "var(--block)" }}>{error.message}</small> : field.help ? <small>{field.help}</small> : null}
    </label>
  );
}

/* Step 2 ------------------------------------------------------------------ */

function MolFilesStep({ engine, files, onAdd, onRemove, samples, samplesSource, setSamples, mapping, onConfirm, mappingConfirmed, busy }) {
  return (
    <div className="tool-stack">
      <div className="grid-2">
        {Object.entries(MOL_SLOTS).map(([slot, meta]) => {
          const list = Array.isArray(files[slot]) ? files[slot] : files[slot] ? [files[slot]] : [];
          return (
            <div key={slot}>
              <ToolDrop title={meta.title} hint={meta.hint} accept={MOL_ACCEPT} multiple={meta.multiple} disabled={Boolean(busy)}
                        onFiles={(fl) => onAdd(slot, meta.multiple ? fl : fl.slice(0, 1))} />
              {list.map((f) => <MolFileRow key={f.id} file={f} onRemove={() => onRemove(slot, f.id)} />)}
            </div>
          );
        })}
      </div>
      <p className="tool-muted" style={{ fontSize: 13, margin: 0 }}>
        Templates: <a className="btn-link" href="/molecular/templates/sample-sheet.csv" download>sample sheet</a>,{" "}
        <a className="btn-link" href="/molecular/templates/qpcr.csv" download>qPCR (tidy)</a>,{" "}
        <a className="btn-link" href="/molecular/templates/viability.csv" download>viability</a>,{" "}
        <a className="btn-link" href="/molecular/templates/confirmation.csv" download>confirmation</a>.
      </p>

      {samples ? (
        <MolSampleTable engine={engine} samples={samples} source={samplesSource} onChange={setSamples} />
      ) : null}

      {files.qpcr.length || files.sampleSheet ? (
        <ToolPanel title="Check the mapping">
          <div style={{ display: "grid", gap: 10 }}>
            {mapping.notes.map((n, i) => <div key={i} style={{ fontSize: 14, color: "var(--ink-700)" }}>{n}</div>)}
            {mapping.errors.length ? <ToolNotice tone="block" title="Fix these first" items={mapping.errors} /> : null}
            {mapping.warnings.length ? <ToolNotice tone="warn" title="Check these" items={mapping.warnings} /> : null}
            <div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center" }}>
              <button type="button" className="btn btn-primary btn-tap" disabled={!mapping.ok} onClick={onConfirm}>
                {mappingConfirmed ? "Mapping confirmed. Show calls" : "Confirm mapping and get calls"}
              </button>
              {samplesSource !== "file" && samples ? (
                <button type="button" className="btn btn-ghost btn-tap" onClick={() => toolDownload("sample-sheet.csv", engine.sampleSheetCsv(samples), "text/csv")}>
                  Download this sample sheet
                </button>
              ) : null}
            </div>
          </div>
        </ToolPanel>
      ) : null}
    </div>
  );
}

function MolFileRow({ file, onRemove }) {
  const p = file.parsed;
  const count = p.wells ? `${p.wells.length} wells` : p.samples ? `${p.samples.length} samples` : p.rows ? `${p.rows.length} rows` : "";
  const fmt = p.format ? { quantstudio: "QuantStudio", cfx: "Bio-Rad CFX", tidy: "table" }[p.format] || p.format : null;
  const problems = (p.errors || []).length;
  return (
    <div className="tool-file">
      <div style={{ minWidth: 0, flex: 1 }}>
        <div className="tool-file-name">{file.name}</div>
        <div className="tool-file-meta">
          {[fmt, count, toolFmtBytes(file.size), `SHA-256 ${file.sha256.slice(0, 10)}…`].filter(Boolean).join(" · ")}
          {problems ? <span className="tool-bad"> · {problems} problem{problems === 1 ? "" : "s"}</span> : null}
        </div>
      </div>
      <button type="button" className="btn btn-ghost btn-sm" style={{ minHeight: 36 }} onClick={onRemove} aria-label={`Remove ${file.name}`}>Remove</button>
    </div>
  );
}

function MolSampleTable({ engine, samples, source, onChange }) {
  const editable = source !== "file";
  const update = (index, key, value) => onChange(samples.map((s, i) => (i === index ? { ...s, [key]: value } : s)));
  const roles = engine.ROLES.map((r) => (typeof r === "string" ? { value: r, label: r.replace(/_/g, " ") } : { value: r.id || r.value, label: r.label }));
  return (
    <ToolPanel title={source === "file" ? "Sample sheet" : "Proposed sample sheet"}
               actions={editable ? <span className="chip chip-warn">Check every row</span> : null}>
      {editable ? (
        <p style={{ margin: "0 0 12px", fontSize: 13.5, color: "var(--ink-700)", lineHeight: 1.5 }}>
          Proposed from the qPCR sample names. Correct any candidate, role, or replicate, then confirm the mapping below.
        </p>
      ) : null}
      <div className="table-scroll" style={{ maxHeight: 420, overflowY: "auto", border: "1px solid var(--ink-200)", borderRadius: 8 }}>
        <table className="tool-table">
          <thead>
            <tr><th>Sample</th><th>Candidate</th><th>Role</th><th>Replicate</th><th>Dose</th></tr>
          </thead>
          <tbody>
            {samples.map((s, i) => (
              <tr key={`${s.sample}-${i}`}>
                <td className="tool-mono">{s.sample}</td>
                <td>{editable ? <input className="tool-input" style={{ minHeight: 36, maxWidth: 140 }} value={s.candidate || ""} onChange={(e) => update(i, "candidate", e.target.value)} /> : s.candidate}</td>
                <td>
                  {editable ? (
                    <select className="tool-input" style={{ minHeight: 36, maxWidth: 190 }} value={s.role} onChange={(e) => update(i, "role", e.target.value)}>
                      {roles.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
                    </select>
                  ) : (roles.find((r) => r.value === s.role)?.label || s.role)}
                </td>
                <td>{editable ? <input className="tool-input" style={{ minHeight: 36, maxWidth: 80 }} value={s.replicate ?? ""} onChange={(e) => update(i, "replicate", e.target.value)} /> : s.replicate}</td>
                <td>{s.dose !== null && s.dose !== undefined && s.dose !== "" ? `${s.dose}${s.unit ? ` ${s.unit}` : ""}` : <span className="tool-muted">none</span>}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </ToolPanel>
  );
}

/* Step 3 ------------------------------------------------------------------ */

function MolCallsStep({ engine, result, mode, selected, setSelected, exclusions, setExclusions, onNext }) {
  const counts = engine.CALLS.map((c) => ({ ...c, n: result.units.filter((u) => u.call === c.id).length })).filter((c) => c.n > 0);
  const unit = result.units.find((u) => u.id === selected) || null;
  const failedRuns = result.runs.filter((r) => r.qc.status === "fail");
  return (
    <div className="tool-stack">
      <div className="tool-stat-row">
        <ToolStat label="Candidates" value={result.units.length} sub={mode === "registered" ? "registered criteria" : "exploratory"} />
        {counts.map((c) => <ToolStat key={c.id} label={c.label} value={c.n} />)}
      </div>

      <MolRunQc result={result} />
      {failedRuns.length ? null : null}

      <ToolPanel title="Candidate calls" actions={<button type="button" className="btn btn-primary btn-sm" onClick={onNext}>Decision pack</button>}>
        <p className="tool-muted" style={{ margin: "0 0 12px", fontSize: 13 }}>Select a candidate to see its wells, the order of checks, and the wording its data supports.</p>
        <div className="table-scroll">
          <table className="tool-table mol-matrix">
            <thead>
              <tr>
                <th>Candidate</th><th>Call</th><th>Deciding check</th><th className="num">Knockdown</th><th className="num">Viability</th><th>Replicates</th><th>Confirmation</th>
              </tr>
            </thead>
            <tbody>
              {result.units.map((u) => {
                // Values from a failed run are shown but greyed: the gate did not use them.
                const unused = u.call === "invalid_run";
                const mute = (v) => (unused ? { ...v, tone: "tool-muted" } : v);
                const kd = mute(molSummaryValue(u.evidence, "activity"));
                const via = mute(molSummaryValue(u.evidence, "viability"));
                const conf = mute(molConfirmationText(u.evidence?.confirmation, u.call));
                const rep = u.evidence?.replication;
                const isSel = u.id === selected;
                return (
                  <tr key={u.id} className={`is-clickable${isSel ? " is-selected" : ""}`} tabIndex={0}
                      onClick={() => setSelected(isSel ? null : u.id)}
                      onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSelected(isSel ? null : u.id); } }}>
                    <td data-label="Candidate" className="tool-mono" style={{ fontWeight: 600, color: "var(--ink-900)", fontSize: 13, whiteSpace: "nowrap" }}>{u.id}</td>
                    <td data-label="Call"><MolCallChip engine={engine} call={u.call} /></td>
                    <td data-label="Deciding check" style={{ minWidth: 220 }}>{u.reason}</td>
                    <td data-label="Knockdown" className={`num ${kd.tone || ""}`}><span>{kd.text}{kd.sub ? <span className="tool-muted" style={{ display: "block", fontSize: 11, fontWeight: 400 }}>{kd.sub}</span> : null}</span></td>
                    <td data-label="Viability" className={`num ${via.tone || ""}`}><span>{via.text}{via.sub ? <span className="tool-muted" style={{ display: "block", fontSize: 11, fontWeight: 400 }}>{via.sub}</span> : null}</span></td>
                    <td data-label="Replicates">{rep ? <MolReplicateText rep={rep} /> : <span className="tool-muted">n/a</span>}</td>
                    <td data-label="Confirmation" className={conf.tone}>{conf.text}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      </ToolPanel>

      {unit ? <MolUnitDetail engine={engine} unit={unit} mode={mode} exclusions={exclusions} setExclusions={setExclusions} onClose={() => setSelected(null)} /> : null}
      {exclusions.length ? <MolExclusions exclusions={exclusions} setExclusions={setExclusions} /> : null}
    </div>
  );
}

function MolReplicateText({ rep }) {
  if (!rep.n) return <span className="tool-warn">none</span>;
  const below = Array.isArray(rep.belowThreshold) ? rep.belowThreshold.length : (rep.belowThreshold || 0);
  const meeting = rep.n - below;
  const tone = rep.status === "fail" ? "tool-bad" : rep.status === "missing" ? "tool-warn" : "";
  return <span className={tone}>{meeting} of {rep.n} meet{rep.required ? ` (${rep.required} needed)` : ""}</span>;
}

function MolRunQc({ result }) {
  const [open, setOpen] = React.useState(false);
  const failed = result.runs.filter((r) => r.qc.status === "fail");
  return (
    <ToolPanel title="Run checks" actions={
      <button type="button" className="btn btn-ghost btn-sm" onClick={() => setOpen(!open)} aria-expanded={open}>{open ? "Hide details" : "Show details"}</button>}>
      <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
        {result.runs.map((run) => (
          <span key={run.id} className={`chip ${run.qc.status === "fail" ? "chip-block" : "chip-ok"}`}>
            {run.assay === "qpcr" ? "qPCR" : "Viability"} · {run.name}: {run.qc.status === "fail" ? "failed" : "passed"}
          </span>
        ))}
      </div>
      {failed.length ? (
        <div style={{ marginTop: 12 }}>
          <ToolNotice tone="block" title="Failed runs make their candidates Invalid run"
                      items={failed.flatMap((r) => r.qc.checks.filter((c) => c.status === "fail").map((c) => `${r.name}: ${c.label}. ${c.detail || ""}`))} />
        </div>
      ) : null}
      {open ? (
        <div className="table-scroll" style={{ marginTop: 12 }}>
          <table className="tool-table">
            <thead><tr><th>Run</th><th>Check</th><th>Result</th><th>Detail</th></tr></thead>
            <tbody>
              {result.runs.flatMap((run) => run.qc.checks.map((c, i) => (
                <tr key={`${run.id}-${c.id}-${i}`}>
                  <td className="tool-mono">{run.name}</td>
                  <td>{c.label}</td>
                  <td className={c.status === "fail" ? "tool-bad" : c.status === "warn" ? "tool-warn" : c.status === "pass" ? "tool-good" : "tool-muted"}>
                    {{ pass: "passed", fail: "failed", warn: "warning", not_checked: "not checked" }[c.status] || c.status}
                  </td>
                  <td style={{ color: "var(--ink-600)" }}>{c.detail}</td>
                </tr>
              )))}
            </tbody>
          </table>
        </div>
      ) : null}
    </ToolPanel>
  );
}

function MolUnitDetail({ engine, unit, mode, exclusions, setExclusions, onClose }) {
  const [excluding, setExcluding] = React.useState(null);
  const activity = unit.evidence?.activity;
  const viability = unit.evidence?.viability;
  const confirmation = unit.evidence?.confirmation;
  const wording = engine.wordingFor ? engine.wordingFor(unit, mode) : unit.wording;
  const wells = [];
  for (const r of activity?.replicates || []) {
    for (const w of r.target?.wells || []) wells.push({ run: r.run, well: w, label: `${molRunName(r.run)} ${w} (${r.sample}, target)` });
    for (const ref of r.references || []) for (const w of ref.wells || []) wells.push({ run: r.run, well: w, label: `${molRunName(r.run)} ${w} (${r.sample}, ${ref.gene})` });
  }
  const outcomeIcon = { cleared: "✓", decided: "●", not_reached: "–" };
  const decidedColor = { ok: "var(--ok)", warn: "var(--warn)", info: "var(--info)", block: "var(--block)", neutral: "var(--ink-500)" }[molCall(engine, unit.call).tone];
  return (
    <ToolPanel title="Candidate detail" actions={<button type="button" className="btn btn-ghost btn-sm" onClick={onClose}>Close</button>}>
      <div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
        <strong className="tool-mono" style={{ fontSize: 15, color: "var(--ink-900)" }}>{unit.id}</strong>
        <MolCallChip engine={engine} call={unit.call} />
        <span style={{ fontSize: 15, color: "var(--ink-800)" }}>{unit.reason}</span>
      </div>

      <h4 style={{ margin: "18px 0 8px", fontSize: 13, textTransform: "uppercase", letterSpacing: ".04em", color: "var(--ink-600)" }}>Order of checks</h4>
      <ol style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 6 }}>
        {unit.chain.map((s) => (
          <li key={s.step} style={{ display: "grid", gridTemplateColumns: "22px minmax(0,1fr)", gap: 8, fontSize: 13.5, color: s.outcome === "not_reached" ? "var(--ink-400)" : "var(--ink-800)" }}>
            <span aria-hidden="true" style={{ color: s.outcome === "decided" ? decidedColor : s.outcome === "cleared" ? "var(--ok)" : "var(--ink-300)", fontWeight: 700 }}>
              {unit.call === "advance" && s.outcome === "decided" ? "✓" : outcomeIcon[s.outcome]}
            </span>
            <span><strong style={{ fontWeight: 600 }}>{s.label}</strong>{s.detail ? <span style={{ color: "var(--ink-500)" }}> · {s.detail}</span> : null}
              <span className="sr-only"> ({s.outcome.replace("_", " ")})</span></span>
          </li>
        ))}
      </ol>

      {activity?.replicates?.length ? (
        <>
          <h4 style={{ margin: "20px 0 8px", fontSize: 13, textTransform: "uppercase", letterSpacing: ".04em", color: "var(--ink-600)" }}>Knockdown by biological replicate</h4>
          <div className="table-scroll">
            <table className="tool-table">
              <thead><tr><th>Rep</th><th>Sample</th><th>Plate</th><th className="num">Target Cq</th><th className="num">Reference Cq</th><th className="num">ΔCq</th><th className="num">ΔΔCq</th><th className="num">Expression</th><th className="num">Knockdown</th></tr></thead>
              <tbody>
                {activity.replicates.map((r, i) => (
                  <tr key={i}>
                    <td>{r.replicate}</td>
                    <td className="tool-mono">{r.sample}</td>
                    <td className="tool-mono">{molRunName(r.run)}</td>
                    <td className="num">{toolFmtNum(r.target?.cq, 2)}{r.target?.censored ? " (limit)" : ""}{r.target?.sd !== null && r.target?.sd !== undefined ? <span className="tool-muted"> ± {toolFmtNum(r.target.sd, 2)}</span> : null}</td>
                    <td className="num">{(r.references || []).map((ref) => `${ref.gene} ${toolFmtNum(ref.cq, 2)}`).join(", ")}</td>
                    <td className="num">{toolFmtNum(r.deltaCq, 2)}</td>
                    <td className="num">{toolFmtNum(r.deltaDeltaCq, 2)}</td>
                    <td className="num">{toolFmtNum(r.normalizedExpression, 3)}</td>
                    <td className="num" style={{ fontWeight: 600 }}>{molPct(r.knockdown, { censored: r.censored, digits: 1 }) || "n/a"}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </>
      ) : null}

      {viability?.replicates?.length ? (
        <>
          <h4 style={{ margin: "20px 0 8px", fontSize: 13, textTransform: "uppercase", letterSpacing: ".04em", color: "var(--ink-600)" }}>Viability by biological replicate</h4>
          <div className="table-scroll">
            <table className="tool-table">
              <thead><tr><th>Rep</th><th>Sample</th><th>Plate</th><th className="num">Signal</th><th className="num">Comparator</th><th className="num">Viability</th></tr></thead>
              <tbody>
                {viability.replicates.map((r, i) => (
                  <tr key={i}>
                    <td>{r.replicate}</td>
                    <td className="tool-mono">{r.sample}</td>
                    <td className="tool-mono">{molRunName(r.plate)}</td>
                    <td className="num">{toolFmtNum(r.signal, 0)}</td>
                    <td className="num">{toolFmtNum(r.comparator ? r.comparator.signal : null, 0)}</td>
                    <td className="num" style={{ fontWeight: 600 }}>{molPct(r.percent, { digits: 1 }) || "n/a"}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </>
      ) : null}

      {confirmation && confirmation.rows && confirmation.rows.length ? (
        <p style={{ marginTop: 16, fontSize: 13.5, color: "var(--ink-700)" }}>
          <strong>Confirmation:</strong> {confirmation.rows.map((r) => (r.status === "pending" ? "pending" : `${toolFmtNum(r.reduction, 0)}% lower`)).join(", ")}
          {confirmation.assay ? ` (${confirmation.assay})` : ""}
        </p>
      ) : null}

      {unit.warnings && unit.warnings.length ? <div style={{ marginTop: 14 }}><ToolNotice tone="warn" title="Warnings" items={unit.warnings.map((w) => w.message || w)} /></div> : null}

      {wording ? (
        <div style={{ marginTop: 18, padding: "14px 16px", background: "var(--brand-50)", border: "1px solid var(--brand-100)", borderRadius: 10 }}>
          <div className="mono" style={{ fontSize: 10.5, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--brand-800)", fontWeight: 600 }}>Wording the data supports</div>
          <p style={{ margin: "8px 0 0", fontSize: 14.5, lineHeight: 1.55, color: "var(--ink-900)" }}>{wording.sentence}</p>
          {wording.doNotSay && wording.doNotSay.length ? (
            <ul style={{ margin: "10px 0 0", paddingLeft: 18, fontSize: 13, color: "var(--ink-700)", lineHeight: 1.5 }}>
              {wording.doNotSay.map((line, i) => <li key={i}>{line}</li>)}
            </ul>
          ) : null}
          <button type="button" className="btn btn-ghost btn-sm" style={{ marginTop: 10, minHeight: 36 }}
                  onClick={() => navigator.clipboard && navigator.clipboard.writeText(wording.sentence)}>Copy sentence</button>
        </div>
      ) : null}

      {wells.length ? (
        <div style={{ marginTop: 18 }}>
          {excluding ? (
            <div style={{ display: "grid", gap: 8, maxWidth: 560 }}>
              <label className="tool-field">
                <span>Well to exclude</span>
                <select className="tool-input" value={excluding.key} onChange={(e) => setExcluding({ ...excluding, key: e.target.value })}>
                  {wells.map((w) => <option key={`${w.run}|${w.well}`} value={`${w.run}|${w.well}`}>{w.label}</option>)}
                </select>
              </label>
              <label className="tool-field">
                <span>Reason</span>
                <input className="tool-input" value={excluding.reason} placeholder="For example: bubble seen in well, pipetting error recorded"
                       onChange={(e) => setExcluding({ ...excluding, reason: e.target.value })} />
                <small>Exclusions are listed in the decision pack with their reason.</small>
              </label>
              <div style={{ display: "flex", gap: 8 }}>
                <button type="button" className="btn btn-primary btn-tap" disabled={!excluding.reason.trim()} onClick={() => {
                  const [run, well] = excluding.key.split("|");
                  setExclusions([...exclusions.filter((x) => !(x.run === run && x.well === well)), { run, well, reason: excluding.reason.trim(), at: new Date().toISOString() }]);
                  setExcluding(null);
                }}>Exclude well</button>
                <button type="button" className="btn btn-ghost btn-tap" onClick={() => setExcluding(null)}>Cancel</button>
              </div>
            </div>
          ) : (
            <button type="button" className="btn btn-ghost btn-tap" onClick={() => setExcluding({ key: `${wells[0].run}|${wells[0].well}`, reason: "" })}>Exclude a well…</button>
          )}
        </div>
      ) : null}
    </ToolPanel>
  );
}

function MolExclusions({ exclusions, setExclusions }) {
  return (
    <ToolPanel title="Exclusions">
      <ul style={{ margin: 0, paddingLeft: 18, fontSize: 13.5, lineHeight: 1.6 }}>
        {exclusions.map((x) => (
          <li key={`${x.run}|${x.well}`}>
            <span className="tool-mono">{molRunName(x.run)} {x.well}</span>: {x.reason}{" "}
            <button type="button" className="btn btn-link" style={{ fontSize: 13 }} onClick={() => setExclusions(exclusions.filter((y) => y !== x))}>Restore</button>
          </li>
        ))}
      </ul>
    </ToolPanel>
  );
}

/* Step 4 ------------------------------------------------------------------ */

function MolPackStep({ engine, pack, review, setReview, study, replay, setReplay, setError }) {
  if (!pack) return <p className="tool-muted">Building the decision pack…</p>;
  const base = toolSlug(study.name, "decision-pack");
  const summary = { candidates: (pack.verdicts || []).length, counts: pack.counts || {}, mode: pack.mode };

  async function checkSavedPack(fileList) {
    try {
      const read = await toolReadFile(fileList[0]);
      const saved = JSON.parse(read.text);
      setReplay({ name: read.name, ...engine.comparePacks(saved, pack) });
    } catch (err) {
      setError(`That file is not a decision pack: ${err.message || err}`);
    }
  }

  return (
    <div className="tool-grid">
      <div className="tool-stack">
        <div className="print-root"><MolPackReport engine={engine} pack={pack} /></div>
      </div>
      <div className="tool-stack no-print" style={{ position: "sticky", top: 76 }}>
        <ToolPanel title="Sign off">
          <div style={{ display: "grid", gap: 10 }}>
            <label className="tool-field"><span>Reviewer</span>
              <input className="tool-input" value={review.reviewer} onChange={(e) => setReview({ ...review, reviewer: e.target.value })} placeholder="Name" />
            </label>
            <label className="tool-field"><span>Role</span>
              <input className="tool-input" value={review.role} onChange={(e) => setReview({ ...review, role: e.target.value })} placeholder="For example: study director" />
            </label>
            <div style={{ display: "flex", gap: 8 }} role="group" aria-label="Review decision">
              {[["accepted", "Accept"], ["rejected", "Reject"]].map(([value, label]) => (
                <button key={value} type="button" className={`btn btn-tap ${review.decision === value ? "btn-primary" : "btn-ghost"}`}
                        aria-pressed={review.decision === value} onClick={() => setReview({ ...review, decision: review.decision === value ? null : value })}>{label}</button>
              ))}
            </div>
            <label className="tool-field"><span>Comment</span>
              <textarea className="tool-input" value={review.comment} onChange={(e) => setReview({ ...review, comment: e.target.value })} />
            </label>
          </div>
        </ToolPanel>
        <ToolPanel title="Export">
          <div style={{ display: "grid", gap: 8 }}>
            <button type="button" className="btn btn-primary btn-tap" onClick={() => window.print()}>Print or save as PDF</button>
            <button type="button" className="btn btn-ghost btn-tap" onClick={() => toolDownload(`${base}.csv`, engine.packCsv(pack), "text/csv")}>Download CSV</button>
            <button type="button" className="btn btn-ghost btn-tap" onClick={() => toolDownload(`${base}.json`, JSON.stringify(pack, null, 2), "application/json")}>Download JSON</button>
            <button type="button" className="btn btn-ghost btn-tap" onClick={() => toolDownload(`${base}-methods.txt`, engine.methodsText(pack))}>Download methods text</button>
          </div>
        </ToolPanel>
        <ToolSaveToAccount module="molecular" defaultTitle={study.name || "Decision pack"} summary={summary}
                           getPayload={async () => ({ pack })} />
        <ToolPanel title="Replay a saved pack">
          <p style={{ margin: "0 0 10px", fontSize: 13.5, color: "var(--ink-700)", lineHeight: 1.5 }}>
            Load the same files and criteria, then a pack JSON saved earlier. The tool recomputes and compares.
          </p>
          <ToolDrop title="Load a decision pack (.json)" accept=".json,application/json" onFiles={checkSavedPack} />
          {replay ? (
            <div style={{ marginTop: 10 }}>
              {replay.same
                ? <ToolNotice tone="ok" title="Same calls, same fingerprint">{replay.name} matches this analysis.</ToolNotice>
                : <ToolNotice tone="block" title={`${replay.differences.length} difference${replay.differences.length === 1 ? "" : "s"}`}
                              items={replay.differences.slice(0, 12).map((d) => `${d.path || d.kind}: saved ${molShow(d.saved)}, now ${molShow(d.recomputed)}`)} />}
            </div>
          ) : null}
        </ToolPanel>
      </div>
    </div>
  );
}

function molShow(value) {
  if (value === null || value === undefined) return "none";
  if (typeof value === "number") return Number.isInteger(value) ? String(value) : value.toFixed(3);
  if (typeof value === "string") return value.length > 24 ? `${value.slice(0, 12)}…` : value;
  return JSON.stringify(value).slice(0, 40);
}

function MolPackReport({ engine, pack }) {
  const verdicts = pack.verdicts || [];
  const exploratory = pack.mode === "exploratory";
  const h = { margin: "26px 0 10px", fontSize: 15, fontWeight: 700, letterSpacing: "-0.01em", color: "var(--ink-900)" };
  const fields = engine.criteriaFields();
  return (
    <article className="card" style={{ padding: "28px 28px 32px" }}>
      <div className="mono" style={{ fontSize: 10.5, letterSpacing: ".1em", textTransform: "uppercase", color: "var(--brand-700)", fontWeight: 600 }}>Decision pack · ConductBiology Molecular</div>
      <h2 style={{ margin: "8px 0 4px", fontSize: 26, fontWeight: 700, letterSpacing: "-0.02em" }}>{pack.study?.name || "Untitled study"}</h2>
      {pack.study?.description ? <p style={{ margin: 0, color: "var(--ink-600)", fontSize: 14 }}>{pack.study.description}</p> : null}
      <div style={{ marginTop: 12 }}>
        {exploratory
          ? <ToolNotice tone="warn" title="Exploratory">Criteria were not locked before results were loaded{pack.amendments?.length ? ", or were amended after" : ""}. These calls cannot be described as preregistered.</ToolNotice>
          : <ToolNotice tone="ok" title="Registered">Criteria were locked {toolFmtTime(pack.criteria?.lock?.lockedAt)}, before results were loaded.</ToolNotice>}
      </div>
      <dl style={{ display: "grid", gridTemplateColumns: "max-content minmax(0,1fr)", gap: "4px 14px", fontSize: 12.5, margin: "14px 0 0", color: "var(--ink-700)" }}>
        <dt className="tool-muted">Adapter</dt><dd style={{ margin: 0 }} className="tool-mono">{pack.engine?.adapter} · engine {pack.engine?.version}</dd>
        <dt className="tool-muted">Decision fingerprint</dt><dd style={{ margin: 0 }} className="tool-mono">{pack.decisionHash}</dd>
        <dt className="tool-muted">Exported</dt><dd style={{ margin: 0 }}>{toolFmtTime(pack.exportedAt)}</dd>
      </dl>

      <h3 style={h}>Calls</h3>
      <div className="table-scroll">
        <table className="tool-table">
          <thead><tr><th>Candidate</th><th>Call</th><th>Deciding check</th></tr></thead>
          <tbody>
            {verdicts.map((v) => (
              <tr key={v.id}><td className="tool-mono" style={{ fontWeight: 600, whiteSpace: "nowrap" }}>{v.id}</td><td><MolCallChip engine={engine} call={v.call} small /></td><td>{v.reason}</td></tr>
            ))}
          </tbody>
        </table>
      </div>

      <h3 style={h}>Criteria</h3>
      <table className="tool-table">
        <tbody>
          {fields.map((f) => {
            const value = engine.getPath(pack.criteria?.values || pack.criteria, f.path);
            const shown = f.type === "boolean" ? (value ? "yes" : "no")
              : f.type === "select" ? (f.options.find((o) => o.value === value)?.label || value)
              : Array.isArray(value) ? value.join(", ") : value === null || value === undefined || value === "" ? "not set" : `${value}${f.unit ? ` ${f.unit}` : ""}`;
            return <tr key={f.path}><td style={{ width: "55%" }}>{f.label}</td><td>{shown}</td></tr>;
          })}
        </tbody>
      </table>
      <p className="tool-muted" style={{ fontSize: 12, marginTop: 8 }}>
        Criteria fingerprint <span className="tool-mono">{pack.criteria?.hash || pack.criteria?.lock?.criteriaHash}</span>
      </p>
      {pack.amendments?.length ? <ToolNotice tone="warn" title="Amendments" items={pack.amendments.map((a) => `${toolFmtTime(a.at)}: ${a.reason}`)} /> : null}

      <MolPackEvidence engine={engine} pack={pack} heading={h} />

      <h3 style={h}>Methods</h3>
      <div style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--ink-800)", whiteSpace: "pre-wrap" }}>{pack.methods || engine.methodsText(pack)}</div>

      <h3 style={h}>Input files</h3>
      <div className="table-scroll">
        <table className="tool-table">
          <thead><tr><th>Role</th><th>File</th><th>SHA-256</th></tr></thead>
          <tbody>{(pack.inputs || []).map((f, i) => <tr key={i}><td>{f.role.replace(/_/g, " ")}</td><td>{f.name}</td><td className="tool-mono">{f.sha256}</td></tr>)}</tbody>
        </table>
      </div>

      <h3 style={h}>Review</h3>
      {/* Only an Accept or Reject counts as a sign-off; a reviewer name alone does not. */}
      {pack.review && pack.review.decision
        ? <p style={{ fontSize: 14, margin: 0 }}>{pack.review.decision === "accepted" ? "Accepted" : "Rejected"} by {pack.review.reviewer || "an unnamed reviewer"}{pack.review.role ? `, ${pack.review.role}` : ""}{pack.review.date ? ` on ${pack.review.date}` : ""}.{pack.review.comment ? ` "${pack.review.comment}"` : ""}</p>
        : <p className="tool-muted" style={{ fontSize: 14, margin: 0 }}>Not yet signed off{pack.review && pack.review.reviewer ? ` (reviewer: ${pack.review.reviewer})` : ""}.</p>}

      <h3 style={h}>Wording the data supports</h3>
      <ul style={{ margin: 0, paddingLeft: 18, fontSize: 13.5, lineHeight: 1.6 }}>
        {verdicts.map((v) => <li key={v.id}><strong className="tool-mono">{v.id}</strong>: {v.wording?.sentence}</li>)}
      </ul>
      <p style={{ marginTop: 16, fontSize: 12.5, color: "var(--ink-600)", lineHeight: 1.5 }}>
        For research use. {pack.limitation}
      </p>
    </article>
  );
}

function MolPackEvidence({ engine, pack, heading }) {
  const runs = pack.runs || pack.qc || [];
  const exclusions = pack.exclusions || [];
  return (
    <>
      <h3 style={heading}>Run checks</h3>
      <ul style={{ margin: 0, paddingLeft: 18, fontSize: 13.5, lineHeight: 1.6 }}>
        {runs.map((run) => (
          <li key={run.id}>
            <span className="tool-mono">{run.name}</span> ({run.assay === "qpcr" ? "qPCR" : "viability"}): {run.qc?.status === "fail" ? <span className="tool-bad">failed</span> : "passed"}
            {run.qc?.checks?.filter((c) => c.status === "fail" || c.status === "warn").map((c, i) => <div key={i} style={{ color: "var(--ink-600)" }}>{c.label}. {c.detail}</div>)}
          </li>
        ))}
      </ul>
      <h3 style={heading}>Exclusions and deviations</h3>
      {exclusions.length
        ? <ul style={{ margin: 0, paddingLeft: 18, fontSize: 13.5, lineHeight: 1.6 }}>{exclusions.map((x, i) => <li key={i}><span className="tool-mono">{molRunName(x.run)} {x.well}</span> {x.sample ? `(${x.sample}${x.target ? `, ${x.target}` : ""})` : ""}: {x.reason}{x.source ? ` (${{ instrument: "instrument file", user: "excluded in the tool", engine: "rule" }[x.source] || x.source})` : ""}</li>)}</ul>
        : <p className="tool-muted" style={{ fontSize: 13.5, margin: 0 }}>None.</p>}
    </>
  );
}

Object.assign(window, { MolecularTool });
