/* Study record: the Assays step (qPCR, plate reader, tables, reference files).
   Engine: window.CBStudy. Shared pieces: chrome.jsx and tools-common.jsx. */

const STUDY_TABLE_ACCEPT = ".csv,.tsv,.txt,.xlsx,.xls";
const STUDY_QPCR_ACCEPT = ".csv,.tsv,.txt,.xlsx,.xls,.rdml,.xml";
const STUDY_HASH_LIMIT = 200 * 1024 * 1024;

const STUDY_KIND_LABEL = { qpcr: "qPCR", plate: "Plate assay", table: "Table" };
const STUDY_FLAG_WORDS = {
  high_sd: "technical SD above the limit", undetermined: "no Cq", partial: "some wells without a Cq", below_range: "below range",
  above_range: "above range", outside_curve: "outside the curve", high_cv: "high CV", unreadable: "unreadable well", low_r2: "low R²",
  no_reference: "no reference gene",
};

function studyRowsOf(engine, read) {
  return read.rows || engine.readTable(read.text || "");
}

function studyFmt(value, digits = 3) {
  if (value === null || value === undefined || !Number.isFinite(value)) return "n/a";
  const abs = Math.abs(value);
  // Floating-point remainders (a calibrator group's mean log2 fold change, for example) read as 0.
  if (abs < 1e-12) return "0";
  if (abs < 0.001 || abs >= 1e6) return value.toExponential(2);
  return Number(value.toPrecision(digits + 1)).toString();
}

function StudyAssaysStep({ engine, state, setState, dataset, run }) {
  async function addFiles(kind, files) {
    await run(`Reading ${files.length} file${files.length === 1 ? "" : "s"}…`, async () => {
      let current = state;
      const problems = [];
      for (const file of files) {
        const read = await toolReadFile(file);
        let made;
        if (kind === "qpcr") {
          const rdml = /\.(rdml|xml)$/i.test(file.name);
          made = await engine.createQpcrAssay(current, read, rdml ? { bytes: new Uint8Array(await file.arrayBuffer()) } : { rows: studyRowsOf(engine, read) });
        } else if (kind === "plate") made = engine.createPlateAssay(current, read, { rows: studyRowsOf(engine, read) });
        else made = engine.createTableAssay(current, read, { rows: studyRowsOf(engine, read) });
        if (made.errors) problems.push(`${file.name}: ${made.errors.join(" ")}`);
        else current = engine.withAssay(current, made.assay);
      }
      setState(current);
      if (problems.length) throw new Error(problems.join(" "));
    });
  }

  async function addReferences(files) {
    await run("Fingerprinting reference files…", async () => {
      const added = [];
      for (const file of files) {
        let sha256 = "";
        if (file.size <= STUDY_HASH_LIMIT) sha256 = await window.CBHash.sha256Hex(new Uint8Array(await file.arrayBuffer()));
        added.push({ name: file.name, size: file.size, type: file.type || "", sha256, subject: null, note: sha256 ? "" : "Over 200 MB: not fingerprinted in the browser.", addedAt: new Date().toISOString() });
      }
      setState((s) => {
        const references = [...s.references];
        for (const ref of added) references.push({ id: `r${references.length + 1}`, ...ref });
        return { ...s, references };
      });
    });
  }

  const update = (id, settings) => setState((s) => engine.updateAssay(s, id, { settings }));
  const remove = (id) => setState((s) => engine.removeAssay(s, id));

  return (
    <div className="tool-stack">
      {state.plan.lockedAt && !state.assays.length ? (
        <ToolNotice tone="ok" title="Your plan is locked">Outcomes from files you add now are registered.</ToolNotice>
      ) : null}
      {!state.plan.lockedAt && !state.assays.length ? (
        <ToolNotice tone="warn" title="No plan is locked">Results will be exploratory. Lock a plan first to make your outcomes registered.</ToolNotice>
      ) : null}
      <div className="grid-2" style={{ gap: 12 }}>
        <ToolDrop title="qPCR export or RDML" hint="QuantStudio, CFX, Design and Analysis, a tidy table, or .rdml" accept={STUDY_QPCR_ACCEPT} multiple onFiles={(f) => addFiles("qpcr", f)} />
        <ToolDrop title="Plate reader export" hint="ELISA and other plate assays: grid, Well and value, or kinetic reads" accept={STUDY_TABLE_ACCEPT} multiple onFiles={(f) => addFiles("plate", f)} />
        <ToolDrop title="Behavior or other table" hint="One row per animal or trial, including EthoVision XT exports" accept={STUDY_TABLE_ACCEPT} multiple onFiles={(f) => addFiles("table", f)} />
        <ToolDrop title="Reference files" hint="Videos, images, ABF, NWB, protocols: recorded by name and fingerprint, not read" multiple onFiles={addReferences} />
      </div>
      {state.assays.map((assay) => {
        const info = dataset.assays[assay.id];
        if (assay.kind === "qpcr") return <StudyQpcrCard key={assay.id} engine={engine} state={state} assay={assay} info={info} update={update} remove={remove} />;
        if (assay.kind === "plate") return <StudyPlateCard key={assay.id} engine={engine} state={state} setState={setState} assay={assay} info={info} update={update} remove={remove} run={run} />;
        return <StudyTableCard key={assay.id} state={state} assay={assay} info={info} update={update} remove={remove} />;
      })}
      {state.references.length ? <StudyReferencesCard state={state} setState={setState} /> : null}
    </div>
  );
}

function StudyAssayHead({ assay, info, extra, remove }) {
  const qc = info ? info.qc : { errors: [], warnings: [], flags: [] };
  const flagCounts = {};
  for (const f of qc.flags) flagCounts[f.kind] = (flagCounts[f.kind] || 0) + 1;
  return (
    <div style={{ display: "grid", gap: 10, marginBottom: 14 }}>
      <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
        <span className="chip">{STUDY_KIND_LABEL[assay.kind]}</span>
        <span className="chip">{assay.log.format || "table"}</span>
        <span className="tool-muted" style={{ fontSize: 12.5 }}>Added {toolFmtTime(assay.addedAt)} · SHA-256 <span className="tool-mono">{assay.file.sha256.slice(0, 12)}…</span></span>
        <span style={{ flex: 1 }} />
        <button type="button" className="btn btn-link btn-tap" onClick={() => remove(assay.id)}>Remove</button>
      </div>
      {extra}
      {qc.errors.map((e, i) => <ToolNotice key={`e${i}`} tone="block" title={e} />)}
      {Object.keys(flagCounts).length ? (
        <ToolNotice tone="warn" title="Flags" items={Object.entries(flagCounts).map(([k, n]) => `${n} × ${STUDY_FLAG_WORDS[k] || k.replace(/_/g, " ")}`)} />
      ) : null}
      {[...(assay.log.warnings || []), ...qc.warnings].length ? (
        <details className="tool-muted" style={{ fontSize: 13 }}>
          <summary style={{ cursor: "pointer", padding: "12px 0", minHeight: 44, boxSizing: "border-box" }}>{[...(assay.log.warnings || []), ...qc.warnings].length} import notes</summary>
          <ul style={{ margin: "6px 0 0", paddingLeft: 18 }}>{[...(assay.log.warnings || []), ...qc.warnings].map((w, i) => <li key={i}>{w}</li>)}</ul>
        </details>
      ) : null}
    </div>
  );
}

function StudyTimepointSelect({ state, value, onChange, label = "Timepoint for labels without one" }) {
  return (
    <label className="tool-field"><span>{label}</span>
      <select className="tool-input" value={value || ""} onChange={(e) => onChange(e.target.value || null)}>
        <option value="">None</option>
        {state.design.timepoints.map((t) => <option key={t} value={t}>{t}</option>)}
      </select>
    </label>
  );
}

function StudyQpcrCard({ engine, state, assay, info, update, remove }) {
  const s = assay.settings;
  const summary = info && info.output ? info.output.summary : null;
  const targets = summary ? summary.targets : [];
  const refs = s.referenceGenes || [];
  const toggleRef = (gene) => update(assay.id, { referenceGenes: refs.includes(gene) ? refs.filter((g) => g !== gene) : [...refs, gene] });
  const samples = summary ? summary.samples.length : 0;
  return (
    <ToolPanel title={assay.file.name}>
      <StudyAssayHead assay={assay} info={info} remove={remove}
                      extra={<div className="tool-muted" style={{ fontSize: 13.5 }}>{assay.data.wells.length} wells · {samples} samples · targets {targets.join(", ")}</div>} />
      <div className="grid-2" style={{ gap: 14 }}>
        <div className="tool-field"><span>Reference genes</span>
          <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
            {targets.map((gene) => (
              <button key={gene} type="button" className={`btn btn-sm ${refs.includes(gene) ? "btn-primary" : "btn-ghost"}`} style={{ minHeight: 40 }}
                      aria-pressed={refs.includes(gene)} onClick={() => toggleRef(gene)}>{gene}</button>
            ))}
          </div>
          <small>Fold change for every other target is relative to these.</small>
        </div>
        <label className="tool-field"><span>Method</span>
          <select className="tool-input" value={s.method} onChange={(e) => update(assay.id, { method: e.target.value })}>
            <option value="ddcq">2^−ΔΔCq (Livak), 100% efficiency</option>
            <option value="pfaffl">Efficiency-corrected (Pfaffl)</option>
          </select>
        </label>
        <label className="tool-field"><span>Control (calibrator) group</span>
          <select className="tool-input" value={s.calibratorGroup || ""} onChange={(e) => update(assay.id, { calibratorGroup: e.target.value || null })}>
            <option value="">The study's control group ({state.design.control || "not set"})</option>
            {state.design.groups.map((g) => <option key={g} value={g}>{g}</option>)}
          </select>
        </label>
        <label className="tool-field"><span>Technical SD limit (cycles)</span>
          <input className="tool-input" type="number" step="0.05" min="0" value={s.maxTechnicalSd ?? 0.5} onChange={(e) => update(assay.id, { maxTechnicalSd: Number(e.target.value) || 0.5 })} />
        </label>
        <StudyTimepointSelect state={state} value={s.timepoint} onChange={(v) => update(assay.id, { timepoint: v })} />
      </div>
      {s.method === "pfaffl" ? (
        <div style={{ marginTop: 14 }}>
          <div className="tool-field"><span>Amplification efficiency per gene (2 = 100%; 95 or 0.95 also work)</span></div>
          <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 10, marginTop: 6 }}>
            {targets.map((gene) => (
              <label key={gene} className="tool-field"><span>{gene}</span>
                <input className="tool-input" type="number" step="0.01" placeholder="2" value={(s.efficiencies || {})[gene] ?? ""}
                       onChange={(e) => update(assay.id, { efficiencies: { ...(s.efficiencies || {}), [gene]: e.target.value === "" ? undefined : Number(e.target.value) } })} />
              </label>
            ))}
          </div>
        </div>
      ) : null}
    </ToolPanel>
  );
}

const STUDY_ROLE_STYLE = {
  standard: { background: "var(--brand-100)", color: "var(--brand-800)" },
  blank: { background: "var(--ink-100)", color: "var(--ink-700)" },
  sample: { background: "var(--info-bg)", color: "#1e40af" },
  control: { background: "var(--warn-bg)", color: "#92400e" },
  empty: { background: "white", color: "var(--ink-400)" },
};

function StudyPlateGrid({ engine, size, wells, values }) {
  const layout = engine.PLATE_LAYOUTS[size || 96] || engine.PLATE_LAYOUTS[96];
  const rows = Array.from({ length: layout.rows }, (_, r) => r);
  const cols = Array.from({ length: layout.columns }, (_, c) => c);
  const cell = layout.columns > 12 ? 30 : 46;
  return (
    <div className="table-scroll">
      <table style={{ borderCollapse: "separate", borderSpacing: 2, fontSize: layout.columns > 12 ? 9 : 10.5 }} aria-label="Plate map">
        <thead><tr><th />{cols.map((c) => <th key={c} style={{ color: "var(--ink-500)", fontWeight: 500 }}>{c + 1}</th>)}</tr></thead>
        <tbody>
          {rows.map((r) => (
            <tr key={r}>
              <th style={{ color: "var(--ink-500)", fontWeight: 500, paddingRight: 4 }}>{String.fromCharCode(65 + r)}</th>
              {cols.map((c) => {
                const id = engine.wellId(r, c);
                const role = wells ? wells[id] : null;
                const type = role ? role.type : "empty";
                const value = values ? values[id] : undefined;
                const label = !role || type === "empty" ? "" : type === "standard" ? `S${role.index}` : type === "blank" ? "Blk" : role.label;
                return (
                  <td key={c} title={`${id}${role && role.label ? `: ${role.label}` : ""}${value !== undefined && value !== null ? ` · ${value}` : ""}`}
                      style={{ ...STUDY_ROLE_STYLE[type], width: cell, minWidth: cell, height: 30, textAlign: "center", borderRadius: 4, border: "1px solid var(--ink-200)", overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis", maxWidth: cell, padding: "0 2px" }}>
                    {label}
                  </td>
                );
              })}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function StudyPlateCard({ engine, state, setState, assay, info, update, remove, run }) {
  const s = assay.settings;
  const result = info && info.output ? info.output.result : null;
  const [paste, setPaste] = React.useState("");
  const [showPaste, setShowPaste] = React.useState(false);
  const read = assay.data.kinetic ? null : (assay.data.reads.find((r) => r.label === s.read) || assay.data.reads[0]);
  const standards = assay.data.map ? Object.values(assay.data.map.wells).filter((w) => w.type === "standard") : [];
  const indexes = [...new Set(standards.map((w) => w.index))].sort((a, b) => a - b);
  const figure = engine.plateFigure(assay, info);

  async function loadMap(files) {
    await run("Reading the plate map…", async () => {
      const file = await toolReadFile(files[0]);
      const parsed = engine.readPlateMap(studyRowsOf(engine, file));
      if (parsed.errors.length) throw new Error(parsed.errors.join(" "));
      setState((st) => ({ ...st, assays: st.assays.map((a) => (a.id === assay.id ? engine.withPlateMap(a, parsed, file) : a)) }));
    });
  }

  async function usePaste() {
    await run("Reading the pasted plate map…", async () => {
      const text = paste.trim();
      const parsed = engine.readPlateMap(engine.readTable(text));
      if (parsed.errors.length) throw new Error(parsed.errors.join(" "));
      const meta = { name: "pasted plate map", size: text.length, sha256: await window.CBHash.sha256Hex(text) };
      setState((st) => ({ ...st, assays: st.assays.map((a) => (a.id === assay.id ? engine.withPlateMap(a, parsed, meta) : a)) }));
      setShowPaste(false);
    });
  }

  return (
    <ToolPanel title={assay.file.name}>
      <StudyAssayHead assay={assay} info={info} remove={remove}
                      extra={<div className="tool-muted" style={{ fontSize: 13.5 }}>{assay.data.size}-well plate · {assay.data.kinetic ? `kinetic, ${assay.data.kinetic.times.length} reads` : `${assay.data.reads.length} read${assay.data.reads.length === 1 ? "" : "s"}`}{assay.data.mapFile ? ` · map ${assay.data.mapFile.name}` : ""}</div>} />
      <div className="grid-2" style={{ gap: 14 }}>
        <label className="tool-field"><span>Analyte</span>
          <input className="tool-input" value={s.analyte} placeholder="For example: IL-6" onChange={(e) => update(assay.id, { analyte: e.target.value })} />
        </label>
        <label className="tool-field"><span>Unit</span>
          <input className="tool-input" value={s.unit} placeholder="pg/mL" onChange={(e) => update(assay.id, { unit: e.target.value })} />
        </label>
        {assay.data.reads.length > 1 ? (
          <label className="tool-field"><span>Reading</span>
            <select className="tool-input" value={s.read || ""} onChange={(e) => update(assay.id, { read: e.target.value })}>
              {assay.data.reads.map((r) => <option key={r.label} value={r.label}>{r.label}</option>)}
            </select>
          </label>
        ) : null}
        <label className="tool-field"><span>Standard curve</span>
          <select className="tool-input" value={s.model} onChange={(e) => update(assay.id, { model: e.target.value })}>
            <option value="4pl">Four-parameter logistic (4PL)</option>
            <option value="5pl">Five-parameter logistic (5PL)</option>
            <option value="linear">Linear</option>
            <option value="none">No standards: use the signal</option>
          </select>
        </label>
        {s.model !== "none" ? (
          <>
            <label className="tool-field"><span>Top standard (Std 1) concentration</span>
              <input className="tool-input" type="number" min="0" step="any" value={s.top ?? ""} placeholder="500" onChange={(e) => update(assay.id, { top: e.target.value === "" ? null : Number(e.target.value) })} />
            </label>
            <label className="tool-field"><span>Dilution factor between standards</span>
              <input className="tool-input" type="number" min="1" step="any" value={s.factor ?? 2} onChange={(e) => update(assay.id, { factor: Number(e.target.value) || 2 })} />
            </label>
          </>
        ) : null}
        <label className="tool-field"><span>Replicate CV limit (%)</span>
          <input className="tool-input" type="number" min="0" step="1" value={s.maxCv ?? 20} onChange={(e) => update(assay.id, { maxCv: Number(e.target.value) || 20 })} />
        </label>
        <StudyTimepointSelect state={state} value={s.timepoint} onChange={(v) => update(assay.id, { timepoint: v })} />
        <label className="tool-field" style={{ alignSelf: "end" }}>
          <span style={{ display: "flex", gap: 8, alignItems: "center", minHeight: 44 }}>
            <input type="checkbox" checked={s.blankSubtract !== false} onChange={(e) => update(assay.id, { blankSubtract: e.target.checked })} style={{ width: 20, height: 20 }} />
            Subtract the blank wells' mean
          </span>
        </label>
      </div>
      {assay.data.kinetic ? (
        <div className="grid-2" style={{ gap: 14, marginTop: 14 }}>
          <label className="tool-field"><span>Rate window start ({assay.data.kinetic.unit === "cycle" ? "cycle" : "seconds"}; blank = steepest stretch)</span>
            <input className="tool-input" type="number" step="any" value={s.kinetic?.start ?? ""} onChange={(e) => update(assay.id, { kinetic: { ...s.kinetic, start: e.target.value === "" ? null : Number(e.target.value) } })} />
          </label>
          <label className="tool-field"><span>Rate window end</span>
            <input className="tool-input" type="number" step="any" value={s.kinetic?.end ?? ""} onChange={(e) => update(assay.id, { kinetic: { ...s.kinetic, end: e.target.value === "" ? null : Number(e.target.value) } })} />
          </label>
        </div>
      ) : null}
      {s.model !== "none" && indexes.length ? (
        <details style={{ marginTop: 12 }}>
          <summary style={{ cursor: "pointer", padding: "12px 0", minHeight: 44, boxSizing: "border-box", fontSize: 13.5 }}>Set standard concentrations one by one</summary>
          <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))", gap: 10, marginTop: 8 }}>
            {indexes.map((index) => {
              const key = `Std ${index}`;
              const fromMap = standards.find((w) => w.index === index && w.concentration !== null && w.concentration !== undefined);
              return (
                <label key={index} className="tool-field"><span>{key}</span>
                  <input className="tool-input" type="number" step="any" min="0" value={(s.concentrations || {})[key] ?? ""}
                         placeholder={fromMap ? String(fromMap.concentration) : s.top ? studyFmt(s.top / (Number(s.factor) || 2) ** (index - 1), 4) : ""}
                         onChange={(e) => update(assay.id, { concentrations: { ...(s.concentrations || {}), [key]: e.target.value === "" ? undefined : Number(e.target.value) } })} />
                </label>
              );
            })}
          </div>
        </details>
      ) : null}

      <div style={{ marginTop: 16 }}>
        <div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center", marginBottom: 10 }}>
          <strong style={{ fontSize: 13.5 }}>Plate map</strong>
          <span className="tool-muted" style={{ fontSize: 12.5 }}>Std 1, Std 2… for standards; Blank; QC or Control; anything else is a sample label, with a dilution such as "M01 (1:2)".</span>
        </div>
        {assay.data.map ? <StudyPlateGrid engine={engine} size={assay.data.size} wells={assay.data.map.wells} values={read ? read.values : null} /> : null}
        <div className="grid-2" style={{ gap: 12, marginTop: 10 }}>
          <ToolDrop title={assay.data.map ? "Replace the plate map" : "Load a plate map"} hint="A grid (rows A to H, columns 1 to 12) or a table with Well and Label columns" accept={STUDY_TABLE_ACCEPT} onFiles={loadMap} />
          <div style={{ display: "grid", gap: 8, alignContent: "start" }}>
            <button type="button" className="btn btn-ghost btn-tap" onClick={() => setShowPaste(!showPaste)}>{showPaste ? "Hide" : "Paste a plate map from a spreadsheet"}</button>
            {showPaste ? (
              <>
                <textarea className="tool-input" rows={6} value={paste} onChange={(e) => setPaste(e.target.value)} placeholder={"Copy the grid with its row letters and column numbers, then paste it here."} />
                <button type="button" className="btn btn-primary btn-tap" disabled={!paste.trim()} onClick={usePaste}>Use this plate map</button>
              </>
            ) : null}
          </div>
        </div>
      </div>

      {result ? (
        <div className="tool-stack" style={{ marginTop: 16 }}>
          {figure ? <StudyFigure figure={figure} name={`standard-curve-${toolSlug(s.analyte || assay.file.name, "plate")}`} /> : null}
          {result.fit ? (
            <div className="tool-muted" style={{ fontSize: 13 }}>
              {result.fit.model.toUpperCase()} fit, R² {result.fit.r2.toFixed(4)}{result.fit.converged ? "" : " (did not fully converge)"}; standard range {result.range ? `${studyFmt(result.range.low)} to ${studyFmt(result.range.high)}${s.unit ? ` ${s.unit}` : ""}` : "n/a"}
            </div>
          ) : null}
          {result.standards.length && result.fit ? (
            <div className="table-scroll">
              <table className="tool-table">
                <thead><tr><th>Standard</th><th className="num">Nominal</th><th className="num">Mean signal</th><th className="num">CV %</th><th className="num">Back-calculated</th><th className="num">Recovery %</th></tr></thead>
                <tbody>
                  {result.standards.map((x) => (
                    <tr key={x.label}><td>{x.label}</td><td className="num">{studyFmt(x.concentration)}</td><td className="num">{studyFmt(x.mean)}</td><td className="num">{studyFmt(x.cv, 2)}</td><td className="num">{studyFmt(x.backCalculated)}</td><td className="num">{studyFmt(x.recovery, 3)}</td></tr>
                  ))}
                </tbody>
              </table>
            </div>
          ) : null}
          <details>
            <summary style={{ cursor: "pointer", padding: "12px 0", minHeight: 44, boxSizing: "border-box", fontSize: 13.5 }}>{result.samples.length} samples</summary>
            <div className="table-scroll" style={{ marginTop: 8 }}>
              <table className="tool-table">
                <thead><tr><th>Sample</th><th className="num">Wells</th><th className="num">Mean signal</th><th className="num">CV %</th><th className="num">Dilution</th><th className="num">{result.fit ? `Concentration${s.unit ? ` (${s.unit})` : ""}` : "Value"}</th><th>Flags</th></tr></thead>
                <tbody>
                  {result.samples.map((x) => (
                    <tr key={x.label}>
                      <td>{x.label}</td><td className="num">{x.wells.length}</td><td className="num">{studyFmt(x.mean)}</td><td className="num">{studyFmt(x.cv, 2)}</td>
                      <td className="num">{x.dilution}</td><td className="num">{studyFmt(result.fit ? x.concentration : x.mean)}</td>
                      <td>{x.flags.map((f) => STUDY_FLAG_WORDS[f] || f).join(", ")}</td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          </details>
        </div>
      ) : null}
    </ToolPanel>
  );
}

function StudyTableCard({ state, assay, info, update, remove }) {
  const s = assay.settings;
  const columns = assay.data.columns;
  const numeric = columns.filter((c) => c.kind === "number");
  const measures = s.measureColumns || [];
  const toggle = (index) => update(assay.id, { measureColumns: measures.includes(index) ? measures.filter((i) => i !== index) : [...measures, index].sort((a, b) => a - b) });
  const preview = assay.data.rows.slice(0, 6);
  return (
    <ToolPanel title={assay.file.name}>
      <StudyAssayHead assay={assay} info={info} remove={remove}
                      extra={<div className="tool-muted" style={{ fontSize: 13.5 }}>{assay.data.rows.length} rows · {columns.length} columns{assay.data.format === "ethovision" ? " · EthoVision XT export" : ""}</div>} />
      <div className="grid-2" style={{ gap: 14 }}>
        <label className="tool-field"><span>Name</span>
          <input className="tool-input" value={s.name} onChange={(e) => update(assay.id, { name: e.target.value })} />
        </label>
        <label className="tool-field"><span>Subject ID column</span>
          <select className="tool-input" value={s.idColumn ?? ""} onChange={(e) => update(assay.id, { idColumn: e.target.value === "" ? null : Number(e.target.value) })}>
            <option value="">Choose a column</option>
            {columns.map((c) => <option key={c.index} value={c.index}>{c.name}</option>)}
          </select>
        </label>
        <label className="tool-field"><span>Timepoint column</span>
          <select className="tool-input" value={s.timepointColumn ?? ""} onChange={(e) => update(assay.id, { timepointColumn: e.target.value === "" ? null : Number(e.target.value) })}>
            <option value="">None</option>
            {columns.map((c) => <option key={c.index} value={c.index}>{c.name}</option>)}
          </select>
        </label>
        {s.timepointColumn === null || s.timepointColumn === undefined
          ? <StudyTimepointSelect state={state} value={s.timepoint} onChange={(v) => update(assay.id, { timepoint: v })} label="Timepoint for every row" />
          : null}
        <label className="tool-field"><span>Rows for the same animal and timepoint</span>
          <select className="tool-input" value={s.aggregate || "mean"} onChange={(e) => update(assay.id, { aggregate: e.target.value })}>
            <option value="mean">Combine by the mean</option>
            <option value="median">Combine by the median</option>
          </select>
        </label>
      </div>
      <div className="tool-field" style={{ marginTop: 14 }}><span>Measures</span>
        <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
          {numeric.map((c) => (
            <button key={c.index} type="button" className={`btn btn-sm ${measures.includes(c.index) ? "btn-primary" : "btn-ghost"}`} style={{ minHeight: 40 }}
                    aria-pressed={measures.includes(c.index)} onClick={() => toggle(c.index)}>{c.name}{c.unit ? ` (${c.unit})` : ""}</button>
          ))}
          {!numeric.length ? <span className="tool-muted" style={{ fontSize: 13 }}>No numeric columns were found.</span> : null}
        </div>
      </div>
      <div className="table-scroll" style={{ marginTop: 14 }}>
        <table className="tool-table">
          <thead><tr>{columns.map((c) => <th key={c.index}>{c.name}</th>)}</tr></thead>
          <tbody>{preview.map((row, r) => <tr key={r}>{columns.map((c) => <td key={c.index}>{row[c.index]}</td>)}</tr>)}</tbody>
        </table>
      </div>
      {assay.data.rows.length > preview.length ? <p className="tool-muted" style={{ fontSize: 12, marginTop: 6 }}>First {preview.length} of {assay.data.rows.length} rows.</p> : null}
    </ToolPanel>
  );
}

function StudyReferencesCard({ state, setState }) {
  const change = (id, changes) => setState((s) => ({ ...s, references: s.references.map((r) => (r.id === id ? { ...r, ...changes } : r)) }));
  const drop = (id) => setState((s) => ({ ...s, references: s.references.filter((r) => r.id !== id) }));
  return (
    <ToolPanel title="Reference files">
      <p className="tool-muted" style={{ margin: "0 0 10px", fontSize: 13 }}>Recorded by name, size, and SHA-256 so the record says exactly which file was used. They are not read or uploaded.</p>
      <div className="table-scroll">
        <table className="tool-table">
          <thead><tr><th>File</th><th className="num">Size</th><th>SHA-256</th><th>Subject</th><th>Note</th><th /></tr></thead>
          <tbody>
            {state.references.map((ref) => (
              <tr key={ref.id}>
                <td style={{ overflowWrap: "anywhere" }}>{ref.name}</td>
                <td className="num">{toolFmtBytes(ref.size || 0)}</td>
                <td><span className="tool-mono">{ref.sha256 ? `${ref.sha256.slice(0, 12)}…` : "not computed"}</span></td>
                <td>
                  <select className="tool-input" value={ref.subject || ""} onChange={(e) => change(ref.id, { subject: e.target.value || null })} aria-label={`Subject for ${ref.name}`}>
                    <option value="">Whole study</option>
                    {state.subjects.map((s) => <option key={s.id} value={s.id}>{s.id}</option>)}
                  </select>
                </td>
                <td><input className="tool-input" value={ref.note || ""} onChange={(e) => change(ref.id, { note: e.target.value })} aria-label={`Note for ${ref.name}`} /></td>
                <td><button type="button" className="btn btn-link btn-tap" onClick={() => drop(ref.id)}>Remove</button></td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </ToolPanel>
  );
}

// An engine SVG shown inline (its text is escaped by the engine), with SVG and PNG downloads.
function StudyFigure({ figure, name }) {
  async function png(scale) {
    const img = new Image();
    const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(figure.svg)}`;
    await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = () => reject(new Error("Could not draw the figure.")); img.src = url; });
    const canvas = document.createElement("canvas");
    canvas.width = Math.round(figure.width * scale);
    canvas.height = Math.round(figure.height * scale);
    const ctx = canvas.getContext("2d");
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
    const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
    toolDownload(`${name}${scale === 4 ? "-4x" : ""}.png`, blob);
  }
  return (
    <div style={{ display: "grid", gap: 8 }}>
      <div style={{ overflowX: "auto" }} dangerouslySetInnerHTML={{ __html: figure.svg.replace("<svg ", '<svg style="max-width:100%;height:auto" ') }} />
      <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
        <button type="button" className="btn btn-ghost btn-sm" style={{ minHeight: 36 }} onClick={() => toolDownload(`${name}.svg`, figure.svg, "image/svg+xml")}>SVG</button>
        <button type="button" className="btn btn-ghost btn-sm" style={{ minHeight: 36 }} onClick={() => png(2)}>PNG 2×</button>
        <button type="button" className="btn btn-ghost btn-sm" style={{ minHeight: 36 }} onClick={() => png(4)}>PNG 4×</button>
      </div>
    </div>
  );
}

Object.assign(window, { StudyAssaysStep, StudyFigure, studyFmt, studyRowsOf, STUDY_TABLE_ACCEPT, STUDY_FLAG_WORDS, STUDY_KIND_LABEL, STUDY_HASH_LIMIT });
