/* finetune.jsx — Flow 6: guided fine-tuning + training run status */

/* ── migrated from Bobby's finetune.js: real base models & training params ── */
const FT_BASE_MODELS = [
  { id: "unsloth/mistral-7b-instruct-v0.2-bnb-4bit", label: "Mistral 7B Instruct v0.2", note: "4-bit · Cloudflare LoRA", recommended: true },
  { id: "unsloth/Llama-3.2-3B-bnb-4bit", label: "Llama 3.2 3B", note: "4-bit" },
  { id: "unsloth/Llama-3.2-1B-bnb-4bit", label: "Llama 3.2 1B", note: "4-bit · faster" },
  { id: "unsloth/Llama-3.1-8B-bnb-4bit", label: "Llama 3.1 8B", note: "4-bit · larger" },
  { id: "unsloth/Qwen2.5-3B-bnb-4bit", label: "Qwen 2.5 3B", note: "4-bit" },
];

// primary params are always shown; advanced ones live behind the toggle
const FT_PARAMS = [
  { key: "maxSteps", label: "Max steps", def: 200, type: "number", min: 1, adv: false },
  { key: "learningRate", label: "Learning rate", def: "0.0002", type: "text", adv: false },
  { key: "batchSize", label: "Batch size (per device)", def: 2, type: "number", min: 1, max: 16, adv: false },
  { key: "gradientAccumulationSteps", label: "Grad. accumulation", def: 4, type: "number", min: 1, adv: false },
  { key: "loraR", label: "LoRA rank (r)", def: 16, type: "number", min: 4, max: 128, adv: true },
  { key: "loraAlpha", label: "LoRA alpha", def: 16, type: "number", min: 4, max: 128, adv: true },
  { key: "maxSeqLength", label: "Max sequence length", def: 2048, type: "number", min: 256, max: 8192, step: 256, adv: true },
  { key: "warmupSteps", label: "Warmup steps", def: 5, type: "number", min: 0, max: 100, adv: true },
];
const FT_PARAM_DEFAULTS = Object.fromEntries(FT_PARAMS.map(p => [p.key, p.def]));

const FT_ALLOWED_EXT = [".txt", ".jsonl", ".json", ".md", ".pdf"];

// ── pure helpers (kept side-effect-free so they can be unit tested) ──
function ftExt(name) {
  const s = String(name || "");
  const i = s.lastIndexOf(".");
  return i < 0 ? "" : s.slice(i).toLowerCase();
}
function ftAllowed(name) { return FT_ALLOWED_EXT.includes(ftExt(name)); }
function ftFormatSize(bytes) {
  const b = Number(bytes) || 0;
  if (b < 1024) return b + " B";
  if (b < 1024 * 1024) return (b / 1024).toFixed(b < 10 * 1024 ? 1 : 0) + " KB";
  return (b / (1024 * 1024)).toFixed(b < 10 * 1024 * 1024 ? 1 : 0) + " MB";
}
// dedupe by name + drop disallowed extensions; returns the merged list plus rejects
function ftAddFiles(existing, incoming) {
  const next = existing.slice();
  const rejected = [];
  const seen = new Set(existing.map(f => f.name));
  for (const f of incoming || []) {
    if (!ftAllowed(f.name)) { rejected.push({ name: f.name, reason: "unsupported type" }); continue; }
    if (seen.has(f.name)) { rejected.push({ name: f.name, reason: "duplicate" }); continue; }
    seen.add(f.name);
    next.push(f);
  }
  return { next, rejected };
}
window.FineTuneHelpers = { FT_ALLOWED_EXT, FT_BASE_MODELS, FT_PARAMS, FT_PARAM_DEFAULTS, ftExt, ftAllowed, ftFormatSize, ftAddFiles };

function StepHead({ n, title, sub, done }) {
  return (
    <div className="row" style={{ gap: 12, marginBottom: 14 }}>
      <div className="step-dot" data-x style={{ background: done ? "var(--primary)" : "var(--surface)", color: done ? "var(--primary-ink)" : "var(--ink)", borderColor: "var(--primary)", flex: "none" }}>{done ? I("check", { style: { width: 14, height: 14 } }) : n}</div>
      <div><div className="h3">{title}</div>{sub && <div className="caption">{sub}</div>}</div>
    </div>
  );
}

function FineTuneSetup({ ctx, onStart, initialFiles, launching }) {
  const { DATASETS, BIZ } = window.DEMO;
  const { user: authUser } = useFirebaseAuth();
  const activeAgent = ctx.activeAgent || window.ModelOSIntegrations.load().activeAgent;
  const [ds, setDs] = React.useState(() => Object.fromEntries(DATASETS.map(d => [d.id, d.on])));
  const [files, setFiles] = React.useState(() => initialFiles || []);
  const [rejected, setRejected] = React.useState([]);
  const [hot, setHot] = React.useState(false);
  const [baseModel, setBaseModel] = React.useState(() => (FT_BASE_MODELS.find(m => m.recommended) || FT_BASE_MODELS[0]).id);
  const [params, setParams] = React.useState(() => ({ ...FT_PARAM_DEFAULTS }));
  const [adv, setAdv] = React.useState(false);
  const [stats, setStats] = React.useState(null);
  const [showUpload, setShowUpload] = React.useState(false);
  const resolveSelectedVersion = () => {
    try {
      const v = window.ModelOSUserStorage.load(authUser?.uid, "selectedDataVersion", null);
      return v && v.modelId === activeAgent?.modelId ? v : null;
    } catch { return null; }
  };
  const versionedName = (v) => `${(activeAgent && activeAgent.name) || BIZ.name} Model ${(v && v.name) || "v1"}`;
  const [dataVersion, setDataVersion] = React.useState(resolveSelectedVersion);
  // Model name is prepopulated with the selected dataset version.
  const [name, setName] = React.useState(versionedName(resolveSelectedVersion()));
  // Re-resolve once auth/agent are available: the initializers above run
  // before Firebase auth resolves on a fresh page load, which made the
  // carried dataset version intermittently disappear.
  React.useEffect(() => {
    const v = resolveSelectedVersion();
    setDataVersion(v);
    setName(n => (!n || n === versionedName(null)) ? versionedName(v) : n);
  }, [authUser?.uid, activeAgent?.modelId]);
  const fileInput = React.useRef(null);

  React.useEffect(() => {
    if (!authUser || !activeAgent?.modelId) return undefined;
    let cancelled = false;
    window.ModelOSIntegrations.loadTrainingDatasetStats(activeAgent.modelId, authUser)
      .then(next => { if (!cancelled) setStats(next); })
      .catch(() => {});
    return () => { cancelled = true; };
  }, [authUser?.uid, activeAgent?.modelId]);

  const liveCount = (d) => {
    if (!d.live) return d.count;
    if (dataVersion && d.live === "approvedTrain") return dataVersion.count;
    if (!stats) return "…";
    return Number(stats[d.live] ?? 0);
  };
  const downloadSplit = (d) => d.live === "evalHoldout" ? "eval" : "train";
  const onDownload = (e, d) => {
    e.stopPropagation();
    if (!activeAgent?.modelId || !authUser) return;
    window.ModelOSIntegrations.downloadTrainingDataset(activeAgent.modelId, downloadSplit(d), authUser)
      .catch(err => window.alert(err.message || "Export failed."));
  };

  const curatedCount = dataVersion && ds.sft ? Number(dataVersion.count || 0) : 0;

  function addFiles(list) {
    const { next, rejected: rej } = ftAddFiles(files, Array.from(list || []));
    setFiles(next);
    setRejected(rej);
  }
  function removeFile(nm) { setFiles(fs => fs.filter(f => f.name !== nm)); }
  function onDrop(e) { e.preventDefault(); setHot(false); addFiles(e.dataTransfer?.files); }
  const setParam = (k, v) => setParams(p => ({ ...p, [k]: v }));

  const canStart = curatedCount > 0 || files.length > 0;

  return (
    <div style={{ display: "grid", gridTemplateColumns: "1fr 320px", gap: 22, alignItems: "start" }} className="ft-grid">
      <div className="stack" style={{ gap: 18 }}>
        {/* step 1 dataset — curated + upload */}
        <div className="card card-pad fu fu-1">
          <StepHead n="1" title="Select or upload a dataset" sub="Toggle curated data, and/or upload your own training files." />
          {dataVersion && (
            <div className="row between" style={{ gap: 10, padding: "9px 12px", marginBottom: 9, background: "var(--primary-soft)", border: "1px solid var(--border)", borderRadius: "var(--r-md)" }}>
              <span className="caption row" style={{ gap: 7 }}>
                {I("layers", { style: { width: 13, height: 13 } })}
                Training data version <b className="mono">{dataVersion.name}</b> ({dataVersion.count} examples) - the prepared dataset will use this snapshot.
              </span>
              <button className="btn btn-ghost btn-sm" onClick={() => ctx.setView("conversations")}>Switch version</button>
            </div>
          )}
          {!dataVersion && (
            <div className="row between" style={{ gap: 10, padding: "12px 14px", marginBottom: 9, background: "var(--surface-2)", border: "1px dashed var(--border)", borderRadius: "var(--r-md)" }}>
              <span className="caption row" style={{ gap: 7 }}>
                {I("alert", { style: { width: 13, height: 13, color: "var(--warn)" } })}
                Training uses an approved dataset version — approve examples in the Training Data Studio and select a version.
              </span>
              <button className="btn btn-secondary btn-sm" onClick={() => ctx.setView("conversations")}>{I("message", { style: { width: 13, height: 13 } })} Open Training Data Studio</button>
            </div>
          )}
          <div className="stack" style={{ gap: 9 }}>
            {DATASETS.filter(d => (d.live === "approvedTrain" && dataVersion) || !d.live).map(d => {
              const locked = !d.live;
              return (
              <div key={d.id} className="tile selectable" data-on={!locked && ds[d.id] ? "1" : "0"} onClick={locked ? undefined : () => setDs(s => ({ ...s, [d.id]: !s[d.id] }))}
                   style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 14px", opacity: locked ? 0.75 : 1, filter: locked ? "grayscale(1)" : undefined, background: locked ? "var(--surface-2)" : undefined, cursor: locked ? "default" : undefined }}>
                <div style={{ width: 34, height: 34, borderRadius: "var(--r-sm)", background: !locked && ds[d.id] ? "var(--primary)" : "var(--surface-2)", color: !locked && ds[d.id] ? "var(--primary-ink)" : "var(--ink-3)", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>{I(locked ? "lock" : d.icon, { style: { width: 17, height: 17 } })}</div>
                <div className="grow">
                  <div style={{ fontSize: 13.5, fontWeight: 600 }}>
                    {d.name}
                    {d.live && dataVersion && d.live === "approvedTrain" && <span className="badge badge-info" style={{ marginLeft: 8, height: 16, fontSize: 9.5, verticalAlign: "2px" }}>{dataVersion.name}</span>}
                  </div>
                  <div className="caption">{locked ? "Contact sales to unlock" : d.desc}{!locked && d.live === "approvedTrain" && dataVersion ? ` · from version ${dataVersion.name}` : ""}</div>
                </div>
                {locked ? (
                  <a className="btn btn-ghost btn-sm" href="mailto:sales@modelos.technology?subject=Unlock%20curated%20datasets" style={{ textDecoration: "none" }}>{I("chat", { style: { width: 13, height: 13 } })} Contact sales</a>
                ) : (
                  <>
                    <span className="badge badge-neutral tnum">{liveCount(d)}</span>
                    {d.live && (
                      <button className="icon-btn" title={`Download ${downloadSplit(d)} .jsonl`}
                              disabled={!activeAgent?.modelId || !authUser}
                              onClick={(e) => onDownload(e, d)}
                              style={{ width: 28, height: 28, flex: "none" }}>{I("download", { style: { width: 14, height: 14 } })}</button>
                    )}
                    <div style={{ width: 20, height: 20, borderRadius: 6, border: ds[d.id] ? "none" : "1.5px solid var(--border-3)", background: ds[d.id] ? "var(--primary)" : "transparent", color: "var(--primary-ink)", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>{ds[d.id] && I("check", { style: { width: 13, height: 13 } })}</div>
                  </>
                )}
              </div>
              );
            })}
          </div>

          {/* upload zone — collapsed by default; live approved dataset is the primary source */}
          {showUpload ? (
            <div className="dropzone" data-hot={hot ? "1" : "0"}
                 onDragOver={e => { e.preventDefault(); setHot(true); }}
                 onDragLeave={() => setHot(false)} onDrop={onDrop}
                 style={{ marginTop: 12, padding: "20px 16px", textAlign: "center", cursor: "pointer" }}
                 onClick={() => fileInput.current && fileInput.current.click()}>
              <div style={{ color: "var(--ink-3)", display: "flex", justifyContent: "center", marginBottom: 8 }}>{I("upload", { style: { width: 22, height: 22 } })}</div>
              <div style={{ fontSize: 13.5, fontWeight: 600 }}>Drop training files or <span style={{ color: "var(--primary)" }}>browse</span></div>
              <div className="caption" style={{ marginTop: 3 }}>{FT_ALLOWED_EXT.join(" · ")}</div>
              <input ref={fileInput} type="file" multiple accept={FT_ALLOWED_EXT.join(",")} style={{ display: "none" }}
                     onChange={e => { addFiles(e.target.files); e.target.value = ""; }} />
            </div>
          ) : (
            <button className="btn btn-ghost btn-sm" style={{ marginTop: 12 }} onClick={() => setShowUpload(true)}>
              {I("plus", { style: { width: 13, height: 13 } })} Upload your own training files
            </button>
          )}

          {rejected.length > 0 && (
            <div className="caption" style={{ marginTop: 8, color: "var(--bad)" }}>
              {I("alert", { style: { width: 12, height: 12, verticalAlign: "-2px" } })} Skipped {rejected.length}: {rejected.map(r => `${r.name} (${r.reason})`).join(", ")}
            </div>
          )}

          {files.length > 0 && (
            <div className="stack" style={{ gap: 6, marginTop: 10 }}>
              {files.map(f => (
                <div key={f.name} className="between" style={{ padding: "9px 11px", background: "var(--surface-2)", borderRadius: "var(--r-md)", border: "1px solid var(--border)" }}>
                  <div className="row" style={{ gap: 9, minWidth: 0 }}>
                    <span style={{ color: "var(--ink-3)", flex: "none" }}>{I("doc", { style: { width: 15, height: 15 } })}</span>
                    <span style={{ fontSize: 13, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{f.name}</span>
                  </div>
                  <div className="row" style={{ gap: 10, flex: "none" }}>
                    <span className="caption tnum">{ftFormatSize(f.size)}</span>
                    <button aria-label="Remove" onClick={() => removeFile(f.name)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--ink-3)", padding: 0, display: "flex" }}>{I("x", { style: { width: 15, height: 15 } })}</button>
                  </div>
                </div>
              ))}
            </div>
          )}
        </div>

        {/* step 2 base model */}
        <div className="card card-pad fu fu-2">
          <StepHead n="2" title="Choose base model" sub="Recommended default matches Bobby's Cloudflare LoRA pipeline." />
          <div style={{ display: "grid", gridTemplateColumns: "repeat(2,1fr)", gap: 10 }}>
            {FT_BASE_MODELS.map(m => (
              <div key={m.id} className="tile selectable" data-on={baseModel === m.id ? "1" : "0"} onClick={() => setBaseModel(m.id)} style={{ padding: 13 }}>
                <div className="between" style={{ marginBottom: 6, gap: 8 }}>
                  <span style={{ fontSize: 13, fontWeight: 700 }}>{m.label}</span>
                  {m.recommended && <span className="badge badge-good" style={{ height: 18, fontSize: 10 }}>Recommended</span>}
                </div>
                <div className="mono caption" style={{ fontSize: 11 }}>{m.note}</div>
              </div>
            ))}
          </div>
        </div>

        {/* step 3 name */}
        <div className="card card-pad fu fu-3">
          <StepHead n="3" title="Name the model" />
          <input className="input" value={name} onChange={e => setName(e.target.value)} style={{ fontWeight: 600 }} />
        </div>

        {/* step 4 config — real, wired training parameter controls */}
        <div className="card card-pad fu fu-4">
          <StepHead n="4" title="Configure training" sub="Smart defaults are chosen for you — edit any value." />
          <div style={{ display: "grid", gridTemplateColumns: "repeat(2,1fr)", gap: 12 }}>
            {FT_PARAMS.filter(p => !p.adv).map(p => (
              <div key={p.key}>
                <label className="field-label">{p.label}</label>
                <input className="input mono" type={p.type} min={p.min} max={p.max} step={p.step}
                       value={params[p.key]} onChange={e => setParam(p.key, e.target.value)} style={{ fontSize: 13 }} />
              </div>
            ))}
          </div>
          <button className="row" onClick={() => setAdv(a => !a)} style={{ gap: 6, marginTop: 14, background: "none", border: "none", color: "var(--ink-2)", cursor: "pointer", fontSize: 12.5, fontWeight: 600, padding: 0 }}>
            {I(adv ? "chevD" : "chevR", { style: { width: 14, height: 14 } })}{I("sliders", { style: { width: 14, height: 14 } })} Advanced settings
          </button>
          {adv && (
            <div className="fu" style={{ display: "grid", gridTemplateColumns: "repeat(2,1fr)", gap: 12, marginTop: 13, paddingTop: 14, borderTop: "1px solid var(--border)" }}>
              {FT_PARAMS.filter(p => p.adv).map(p => (
                <div key={p.key}>
                  <label className="field-label">{p.label}</label>
                  <input className="input mono" type={p.type} min={p.min} max={p.max} step={p.step}
                         value={params[p.key]} onChange={e => setParam(p.key, e.target.value)} style={{ fontSize: 13 }} />
                </div>
              ))}
            </div>
          )}
        </div>

      </div>

      {/* summary */}
      <div className="card card-pad fu fu-2 stack" style={{ gap: 16, position: "sticky", top: 20 }}>
        <span className="eyebrow">Run summary</span>
        <div className="stack" style={{ gap: 11 }}>
          <div className="stack" style={{ gap: 2 }}><span className="caption">Model name</span><b style={{ fontSize: 13 }}>{name}</b></div>
          <div className="between"><span className="caption" style={{ whiteSpace: "nowrap" }}>Base</span><b className="mono" style={{ fontSize: 11.5, textAlign: "right" }}>{FT_BASE_MODELS.find(m => m.id === baseModel).label}</b></div>
          <div className="between"><span className="caption" style={{ whiteSpace: "nowrap" }}>Curated examples</span><b className="tnum">{curatedCount}</b></div>
          <div className="between"><span className="caption" style={{ whiteSpace: "nowrap" }}>Uploaded files</span><b className="tnum">{files.length}</b></div>
          <div className="between"><span className="caption" style={{ whiteSpace: "nowrap" }}>Max steps</span><b className="tnum">{params.maxSteps}</b></div>
        </div>
        <div className="divider" />
        <button className="btn btn-primary btn-lg btn-block" onClick={() => onStart({ name, baseModel, params, curated: Object.keys(ds).filter(k => ds[k]), files, modelId: activeAgent?.modelId, approvedSelected: ds.sft, versionId: dataVersion?.versionId || null })} disabled={!canStart || launching}>{I("play")} {launching ? "Starting..." : "Start fine-tuning"}</button>
      </div>
      <style>{`@media(max-width:980px){.ft-grid{grid-template-columns:1fr !important}}`}</style>
    </div>
  );
}
window.FineTuneSetup = FineTuneSetup;

/* ── Jobs: launch flow, My Jobs list, status cards, completed actions ── */

function ftBadgeClass(s) {
  return ({ completed: "badge-good", failed: "badge-bad", canceled: "badge-neutral", cancelled: "badge-neutral", queued: "badge-warn" })[s] || "badge-info";
}
function ftDotColor(s) {
  return ({ completed: "var(--good)", failed: "var(--bad)", canceled: "var(--ink-3)", cancelled: "var(--ink-3)", queued: "var(--warn)" })[s] || "var(--primary)";
}
function ftModelLabel(id) { const m = FT_BASE_MODELS.find(x => x.id === id); return m ? m.label : (id || "—"); }
function ftAgo(ts) {
  const s = Math.max(0, (Date.now() - ts) / 1000);
  if (s < 60) return "just now";
  const m = s / 60; if (m < 60) return Math.floor(m) + "m ago";
  const h = m / 60; if (h < 24) return Math.floor(h) + "h ago";
  return Math.floor(h / 24) + "d ago";
}

function LossSparkline({ pts, color, label, live }) {
  if (pts.length < 2) return null;
  const W = 220, H = 110, PAD = 24;
  const steps = pts.map(p => p.step);
  const losses = pts.map(p => p.loss);
  const minStep = Math.min(...steps), maxStep = Math.max(...steps);
  const minLoss = Math.min(...losses), maxLoss = Math.max(...losses);
  const x = s => PAD + (maxStep === minStep ? 0 : (s - minStep) / (maxStep - minStep)) * (W - 2 * PAD);
  const y = l => H - PAD - (maxLoss === minLoss ? 0.5 : (l - minLoss) / (maxLoss - minLoss)) * (H - 2 * PAD);
  const d = pts.map((p, i) => `${i ? "L" : "M"}${x(p.step).toFixed(1)},${y(p.loss).toFixed(1)}`).join(" ");
  const last = pts[pts.length - 1];
  return (
    <div style={{ flex: 1, minWidth: 0 }}>
      <div className="row between" style={{ marginBottom: 3, padding: "0 2px" }}>
        <span className="caption" style={{ fontWeight: 600, color }}>{label}</span>
        <span className="caption tnum" style={{ fontWeight: 600 }}>{last.loss.toFixed(4)}</span>
      </div>
      <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", display: "block" }}>
        <line x1={PAD} y1={H - PAD} x2={W - PAD} y2={H - PAD} stroke="var(--border-3)" strokeWidth="1" />
        <line x1={PAD} y1={PAD} x2={PAD} y2={H - PAD} stroke="var(--border-3)" strokeWidth="1" />
        <path d={d} fill="none" stroke={color} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
        {live && (
          <>
            <circle cx={x(last.step)} cy={y(last.loss)} r="8" fill={color} opacity="0.15">
              <animate attributeName="r" values="4;10;4" dur="1.5s" repeatCount="indefinite" />
              <animate attributeName="opacity" values="0.25;0.05;0.25" dur="1.5s" repeatCount="indefinite" />
            </circle>
            <circle cx={x(last.step)} cy={y(last.loss)} r="3.5" fill={color} stroke="var(--surface-2)" strokeWidth="1.5" />
          </>
        )}
        {!live && <circle cx={x(last.step)} cy={y(last.loss)} r="3" fill={color} />}
      </svg>
      <div className="between caption" style={{ marginTop: 2, padding: "0 2px" }}>
        <span className="tnum">step {minStep}</span>
        <span className="tnum">step {maxStep}</span>
      </div>
    </div>
  );
}

function LossChart({ history, live }) {
  const pts = (history || [])
    .filter(p => p && Number.isFinite(Number(p.loss)) && Number.isFinite(Number(p.step)))
    .map(p => ({ type: p.type === "eval" ? "eval" : "train", step: Number(p.step), loss: Number(p.loss) }))
    .sort((a, b) => a.step - b.step);
  if (pts.length < 2) return null;
  const trainPts = pts.filter(p => p.type === "train");
  const evalPts = pts.filter(p => p.type === "eval");
  if (trainPts.length < 2 && evalPts.length < 2) return null;
  const pending = label => (
    <div className="stack" style={{ gap: 6, justifyContent: "center", minHeight: 74, padding: "10px 12px", border: "1px dashed var(--border)", borderRadius: "var(--r-sm)", alignItems: "flex-start" }}>
      <span className="caption" style={{ fontWeight: 700 }}>{label}</span>
      <span className="caption" style={{ color: "var(--ink-3)" }}>Waiting for the first points…</span>
    </div>
  );
  return (
    <div style={{ marginTop: 12, padding: "12px 14px", background: "var(--surface-2)", border: "1px solid var(--border)", borderRadius: "var(--r-md)" }}>
      <div className="caption" style={{ fontWeight: 700, marginBottom: 8 }}>Loss curves</div>
      {/* Both panes always render so one curve never stretches full-width. */}
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14 }}>
        {trainPts.length >= 2 ? <LossSparkline pts={trainPts} color="var(--primary)" label="Training loss" live={live} /> : pending("Training loss")}
        {evalPts.length >= 2 ? <LossSparkline pts={evalPts} color="var(--accent)" label="Evaluation loss" live={false} /> : pending("Evaluation loss")}
      </div>
    </div>
  );
}

function JobCard({ job, expanded, onToggle, onCancel, onDelete, onUse, onEvaluate }) {
  const active = window.FineTuneApi.isActive(job.status);
  const done = job.status === "completed";
  return (
    <div className="card card-pad fu" style={{ borderColor: done ? "var(--good-border)" : "var(--border)" }}>
      <div className="between wrap" style={{ gap: 12 }}>
        <div className="row" style={{ gap: 12, minWidth: 0 }}>
          <span style={{ width: 10, height: 10, borderRadius: "50%", background: ftDotColor(job.status), flex: "none" }} className={active ? "pulse" : ""} />
          <div style={{ minWidth: 0 }}>
            <div className="h3" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{job.name}</div>
            <div className="caption row" style={{ gap: 8, flexWrap: "wrap" }}>
              <span className={cx("badge", ftBadgeClass(job.status))} style={{ height: 18, fontSize: 10, textTransform: "capitalize" }}>{job.status}</span>
              <span>{ftAgo(job.createdAt)}</span>
              {job.shared && <span className="badge badge-neutral" style={{ height: 18, fontSize: 10 }}>Shared</span>}
            </div>
          </div>
        </div>
        <div className="row" style={{ gap: 8, flex: "none" }}>
          <button className="btn btn-ghost btn-sm" onClick={onToggle}>{I(expanded ? "chevD" : "chevR", { style: { width: 14, height: 14 } })} Details</button>
          {active && <button className="btn btn-ghost btn-sm" style={{ color: "var(--bad)" }} onClick={() => onCancel(job)}>Cancel</button>}
          {job.status === "failed" && <button className="btn btn-ghost btn-sm" style={{ color: "var(--bad)" }} onClick={() => onDelete(job)}>{I("trash", { style: { width: 14, height: 14 } })} Delete</button>}
        </div>
      </div>

      {/* active → progress + status message */}
      {active && (
        <div style={{ marginTop: 14 }}>
          <div className="between" style={{ marginBottom: 6 }}>
            <span className="caption">{job.statusMessage}</span>
            <span className="caption tnum">{job.overall}%</span>
          </div>
          <div className="bar"><i style={{ width: job.overall + "%" }} /></div>
          {job.status === "training" && job.metrics && (
            <div className="row" style={{ gap: 16, marginTop: 10 }}>
              <span className="caption">Step <b className="tnum">{job.metrics.step}/{job.metrics.maxSteps}</b></span>
              <span className="caption">Train loss <b className="mono tnum">{job.metrics.loss != null ? Number(job.metrics.loss).toFixed(4) : "-"}</b></span>
              {job.metrics.evalLoss != null && <span className="caption">Eval loss <b className="mono tnum" style={{ color: "var(--accent)" }}>{Number(job.metrics.evalLoss).toFixed(4)}</b></span>}
            </div>
          )}
          {job.status === "queued" && job.queuePosition && (
            <div className="caption" style={{ marginTop: 8, color: "var(--warn-strong)" }}>{I("clock", { style: { width: 12, height: 12, verticalAlign: "-2px" } })} Queue position {job.queuePosition} — waiting for current job to finish.</div>
          )}
        </div>
      )}

      {/* failed */}
      {job.status === "failed" && (
        <div className="caption" style={{ marginTop: 12, color: "var(--bad)" }}>{I("xCircle", { style: { width: 13, height: 13, verticalAlign: "-2px" } })} Training failed. You can delete this record.</div>
      )}

      {/* completed */}
      {done && (
        <div style={{ marginTop: 14 }}>
          <div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
            {job.isOwner !== false && <button className="btn btn-primary btn-sm" onClick={() => onUse(job)}>{I("rocket", { style: { width: 14, height: 14 } })} Use this model</button>}
            <button className="btn btn-ghost btn-sm" onClick={() => onEvaluate(job)}>{I("gauge", { style: { width: 14, height: 14 } })} Evaluate</button>
          </div>
        </div>
      )}
      {/* details drawer */}
      {expanded && (
        <>
        <div className="fu" style={{ marginTop: 14, paddingTop: 14, borderTop: "1px solid var(--border)", display: "grid", gridTemplateColumns: "repeat(2,1fr)", gap: 10 }}>
          <div className="caption">Created<br /><b>{ftAgo(job.createdAt)}</b></div>
          <div className="caption">Status<br /><b style={{ textTransform: "capitalize" }}>{job.status}</b></div>
          {job.params && <div className="caption">Training steps<br /><b className="tnum">{job.params.maxSteps}</b></div>}
          {job.metrics && <div className="caption">Progress<br /><b className="tnum">{job.metrics.step}/{job.metrics.maxSteps}</b></div>}
        </div>
        {(job.lossHistory || []).length >= 2 && <LossChart history={job.lossHistory} live={job.status === "training"} />}
        </>
      )}
    </div>
  );
}


function FineTuneChatModal({ job, knowledgeModelId, onClose }) {
  const [served, setServed] = React.useState(null);
  const [deploying, setDeploying] = React.useState(true);
  const [error, setError] = React.useState(null);
  const [messages, setMessages] = React.useState([]);
  const [draft, setDraft] = React.useState("");
  const [sending, setSending] = React.useState(false);
  const [warm, setWarm] = React.useState(false);
  const [warming, setWarming] = React.useState(false);
  const [warmMessage, setWarmMessage] = React.useState("Starting GPU machine");
  const [servingStatus, setServingStatus] = React.useState(null);
  const [warmStartedAt, setWarmStartedAt] = React.useState(null);
  const [warmTick, setWarmTick] = React.useState(Date.now());
  const [warmRetry, setWarmRetry] = React.useState(0);
  const logRef = React.useRef(null);

  React.useEffect(() => {
    let alive = true;
    setDeploying(true);
    setError(null);
    setServed(null);
    setWarm(false);
    setWarming(false);
    setWarmMessage("Starting GPU machine");
    setServingStatus(null);
    window.FineTuneApi.useModel(job.jobId)
      .then(result => { if (alive) setServed(result); })
      .catch(e => { if (alive) setError(e.message || "Could not prepare this model for serving."); })
      .finally(() => { if (alive) setDeploying(false); });
    return () => { alive = false; };
  }, [job.jobId]);

  React.useEffect(() => {
    if (!served || error) return;
    let alive = true;
    let timer = null;
    let clock = null;
    // Warm start persists across window closes: the GPU warms server-side,
    // so elapsed time is tracked per model rather than per window.
    const warmKey = `warmStart:${served.modelId}`;
    let startedAt = Date.now();
    try {
      const stored = Number(window.localStorage.getItem(warmKey));
      if (Number.isFinite(stored) && stored > 0) startedAt = stored;
      else window.localStorage.setItem(warmKey, String(startedAt));
    } catch {}
    setWarm(false);
    setWarming(true);
    setWarmStartedAt(startedAt);
    setWarmTick(startedAt);
    setWarmMessage("Starting GPU machine");
    setServingStatus(null);
    clock = setInterval(() => setWarmTick(Date.now()), 10000);

    // Fire a warmup ping so KubeAI scales the model pod from zero immediately.
    // The ping itself times out; it only needs to reach the cluster to trigger.
    window.FineTuneApi.warmup({ model: served.modelId }).catch(() => {});

    async function poll() {
      try {
        const result = await window.FineTuneApi.status({ model: served.modelId });
        if (!alive) return;
        setServingStatus(result || null);
        setWarmMessage((result && result.label) || "Starting GPU machine");
        // Only unlock chat when a serving replica is actually ready - the pod can
        // report Running before vLLM can accept completions.
        const replicasReady = !result || !result.replicas || Number(result.replicas.ready || 0) >= 1;
        if (result && result.ready && replicasReady) {
          try { window.localStorage.removeItem(`warmStart:${served.modelId}`); } catch {}
          setWarm(true);
          setWarming(false);
          setWarmMessage("Ready for chat");
          return;
        }
      } catch (statusError) {
        if (!alive) return;
        setWarmMessage("Starting GPU machine");
        setServingStatus({
          ready: false,
          label: "Starting GPU machine",
          message: statusError.message || "Waiting for serving status from the GPU cluster.",
          etaSecondsMin: 600,
          etaSecondsMax: 900
        });
      }
      if (alive) timer = setTimeout(poll, 10000);
    }

    poll();
    return () => { alive = false; if (timer) clearTimeout(timer); if (clock) clearInterval(clock); };
  }, [served && served.modelId, error, warmRetry]);

  React.useEffect(() => {
    if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
  }, [messages, sending]);

  // Toggle: set to true for streaming tokens, false for the classic
  // full-response wait. Both paths produce identical results.
  const CHAT_STREAMING = true;

  async function send(e) {
    if (e) e.preventDefault();
    const text = draft.trim();
    if (!text || sending || !served || !warm || error) return;
    const next = messages.concat({ role: "user", content: text });
    setMessages(next);
    setDraft("");
    setSending(true);
    const payload = {
      model: served.modelId,
      messages: next.map(m => ({ role: m.role, content: m.content })),
      maxTokens: 512,
      temperature: 0.7,
      botModelId: knowledgeModelId || null,
      useKnowledge: true
    };
    try {
      if (CHAT_STREAMING) {
        // Streaming: show an empty assistant bubble, then fill it live.
        const assistantIdx = next.length;
        setMessages(next.concat({ role: "assistant", content: "", streaming: true }));
        let gotFirstToken = false;
        const result = await window.FineTuneApi.chatStream(payload, (delta, full) => {
          if (!gotFirstToken) {
            gotFirstToken = true;
            // First token arrived; replace "Thinking..." state.
          }
          setMessages(prev => prev.map((m, i) =>
            i === assistantIdx ? { ...m, content: full, streaming: false } : m
          ));
        });
        // Ensure the final message is clean.
        setMessages(prev => prev.map((m, i) =>
          i === assistantIdx ? { ...m, content: String(result.reply || "").trim(), mode: result.mode, model: result.model || served.modelId } : m
        ));
      } else {
        // Non-streaming: wait for the full response.
        const chatPromise = window.FineTuneApi.chat(payload);
        const result = await Promise.race([
          chatPromise,
          new Promise((_, reject) => setTimeout(() => reject(new Error("The model is still starting on the GPU.")), 90000)),
        ]);
        setMessages(next.concat({
          role: "assistant",
          content: String(result.reply || "").trim(),
          mode: result.mode,
          model: result.model || served.modelId
        }));
      }
    } catch (e2) {
      const msg = String(e2.message || "");
      const retryable = /still starting|still finishing|timed out|timeout|504|starting on the GPU/i.test(msg);
      if (retryable) {
        setMessages(next.slice(0, -1));
        setDraft(text);
        setWarm(false);
        setWarming(true);
        setWarmMessage("Model still finishing startup - your message will send when it is ready");
        setWarmRetry(n => n + 1);
      } else {
        // Remove any partial streaming bubble before showing the error.
        setMessages(prev => {
          const base = prev.filter(m => !m.streaming);
          return base.concat({ role: "assistant", content: msg || "The model request failed.", error: true });
        });
      }
    } finally {
      setSending(false);
    }
  }

  const warmElapsedMinutes = warmStartedAt ? Math.floor(Math.max(0, warmTick - warmStartedAt) / 60000) : 0;
  const etaMin = Math.ceil((servingStatus?.etaSecondsMin ?? 480) / 60);
  const etaMax = Math.ceil((servingStatus?.etaSecondsMax ?? 720) / 60);
  const warmEta = warm
    ? "Ready now"
    : etaMin === etaMax
      ? `Estimated wait: about ${etaMin} min`
      : `Estimated wait: about ${etaMin}-${etaMax} min`;
  const warmDetail = servingStatus?.message || "You can close this window — the GPU warms in the background and chat unlocks next time you open it.";

  return (
    <div className="scrim" role="presentation" onMouseDown={(e) => { if (e.target === e.currentTarget && !sending) onClose(); }}>
      <div className="modal" role="dialog" aria-modal="true" aria-labelledby="ft-chat-title" style={{ maxWidth: 760, height: "min(760px, 88vh)" }}>
        <div className="between" style={{ gap: 12, padding: "16px 18px", borderBottom: "1px solid var(--border)" }}>
          <div style={{ minWidth: 0 }}>
            <div id="ft-chat-title" className="h3" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>Chat with {job.name}</div>
            <div className="caption" style={{ marginTop: 3 }}>Fine-tuned model chat</div>
          </div>
          <div className="row" style={{ gap: 10, flex: "none" }}>
            <button className="icon-btn" onClick={onClose} aria-label="Close" disabled={sending}>{I("x")}</button>
          </div>
        </div>

        <div className="stack" style={{ gap: 10, padding: "12px 18px", borderBottom: "1px solid var(--border)" }}>
          {deploying && (
            <div className="row" style={{ gap: 9, fontSize: 13.5, color: "var(--ink-2)" }}>
              <span className="spin">{I("refresh", { style: { width: 15, height: 15 } })}</span>
              Preparing your model...
            </div>
          )}
          {!deploying && served && !warm && (
            <div className="stack" style={{ gap: 6 }}>
              <div className="row" style={{ gap: 9, fontSize: 13.5, color: "var(--ink-2)" }}>
                <span className="spin">{I("refresh", { style: { width: 15, height: 15 } })}</span>
                <span>{warmMessage || "Starting GPU machine"}</span>
              </div>
              <div className="caption">{warmEta}{warmStartedAt ? ` - warming for ${warmElapsedMinutes} min` : ""}</div>
              <div className="caption">{warmDetail} You can close this window — the GPU keeps warming in the background.</div>
            </div>
          )}
          {!deploying && served && warm && (
            <div className="between wrap" style={{ gap: 8 }}>
              <span className="badge badge-good">{I("checkCircle")} Ready for chat</span>
            </div>
          )}
          {error && (
            <div className="row" style={{ gap: 9, color: "var(--bad)", fontSize: 13.5 }}>
              {I("alert", { style: { width: 16, height: 16, flex: "none" } })}
              <span>{error}</span>
            </div>
          )}
        </div>

        <div ref={logRef} className="stack grow scroll-y" style={{ gap: 10, padding: 18, background: "var(--bg)" }}>
          {!messages.length && !sending && (
            <div className="stack" style={{ alignItems: "center", textAlign: "center", gap: 8, margin: "auto", color: "var(--ink-3)", maxWidth: 430 }}>
              <span>{I("message", { style: { width: 28, height: 28 } })}</span>
              <div className="h3" style={{ color: "var(--ink-2)" }}>Ask the completed fine-tune directly</div>
              <div className="caption">When the model has scaled down, we start a GPU machine and unlock chat automatically.</div>
            </div>
          )}
          {messages.map((m, i) => (
            <div key={i} className={cx("bubble", m.role === "user" ? "user" : "bot")} style={{ whiteSpace: "pre-wrap", borderColor: m.error ? "var(--bad-border)" : undefined, color: m.error ? "var(--bad)" : undefined }}>
              {m.content}
            </div>
          ))}
          {sending && !messages.some(m => m.role === "assistant" && m.streaming !== undefined) && <div className="bubble bot" style={{ display: "inline-flex", gap: 6, alignSelf: "flex-start" }}>{I("dot3", { style: { width: 18, height: 18 } })} Thinking...</div>}
        </div>

        <form onSubmit={send} className="row" style={{ gap: 10, padding: 14, borderTop: "1px solid var(--border)", background: "var(--surface)" }}>
          <textarea className="textarea" value={draft} disabled={!served || !warm || sending || !!error} placeholder={error ? "Serving is not available yet" : (!warm ? "Waiting for the model to be ready..." : "Message this fine-tuned model")}
            onChange={e => setDraft(e.target.value)}
            onKeyDown={e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }}
            style={{ minHeight: 46, maxHeight: 120, resize: "vertical" }} />
          <button className="btn btn-primary" disabled={!served || !warm || sending || !draft.trim() || !!error}>{I("send")} Send</button>
        </form>
      </div>
    </div>
  );
}

function ftParseHeldoutJsonl(text) {
  return String(text || "")
    .split(/\r?\n/)
    .map(line => line.trim())
    .filter(Boolean)
    .map((line, index) => {
      try {
        const row = JSON.parse(line);
        return {
          id: row.record_id || row.id || `case-${index + 1}`,
          instruction: String(row.instruction || "").trim(),
          input: String(row.input || row.question || row.prompt || "").trim(),
          expected: String(row.output || row.answer || "").trim()
        };
      } catch (error) {
        throw new Error("Line " + (index + 1) + " is not valid JSON.");
      }
    })
    .filter(row => row.input);
}


function ftParseGoldRubricJsonl(text) {
  return String(text || "")
    .split(/\r?\n/)
    .map(line => line.trim())
    .filter(Boolean)
    .map((line, index) => {
      try {
        const row = JSON.parse(line);
        return {
          id: row.record_id || row.id || `case-${index + 1}`,
          input: String(row.input || row.question || row.prompt || "").trim(),
          goldLabel: String(row.gold_label || row.label || "").trim(),
          goldAnswer: String(row.gold_answer || row.answer || row.output || "").trim(),
          requiredPoints: Array.isArray(row.required_points) ? row.required_points : [],
          requiredMetrics: Array.isArray(row.required_metrics) ? row.required_metrics : [],
          bonusDepth: Array.isArray(row.bonus_depth) ? row.bonus_depth : [],
          forbiddenClaims: Array.isArray(row.forbidden_claims) ? row.forbidden_claims : [],
          forbiddenNeighborRules: Array.isArray(row.forbidden_neighbor_rules) ? row.forbidden_neighbor_rules : [],
          decisionRule: String(row.decision_rule || "").trim(),
          decisiveVariable: String(row.decisive_variable || "").trim(),
          decisiveFacts: row.decisive_facts || null,
          evidenceLocators: Array.isArray(row.evidence_locators) ? row.evidence_locators : [],
          semanticClusterId: String(row.semantic_cluster_id || "").trim(),
          difficulty: String(row.difficulty || "").trim(),
          evidenceLocator: String(row.evidence_locator || "").trim(),
          instruction: String(row.instruction || "").trim(),
          scoring: row.scoring && typeof row.scoring === "object" ? row.scoring : null,
        };
      } catch (error) {
        throw new Error("Line " + (index + 1) + " is not valid JSON.");
      }
    })
    .filter(row => row.input || row.goldAnswer);
}

function ftGoldForCase(item, goldRubric) {
  const id = String(item.caseId || item.id || "").trim().toLowerCase();
  const input = String(item.input || "").trim().toLowerCase();
  return (goldRubric || []).find(row => String(row.id || "").trim().toLowerCase() === id)
    || (goldRubric || []).find(row => String(row.input || "").trim().toLowerCase() === input)
    || (goldRubric || []).find(row => row.universal) // built-in rubric applies to every case
    || null;
}
const FT_DEFAULT_METRICS_CONFIG = {
  version: "1.0",
  principle: "Reference is a coverage baseline, not a quality ceiling.",
  score_range: [0, 100],
  accuracy_score: {
    weights: {
      decision_correctness: 0.4,
      critical_point_recall: 0.3,
      factual_correctness: 0.2,
      internal_consistency: 0.1
    },
    critical_point_coverage_factors: {
      fully_covered: 1,
      mostly_covered: 0.75,
      partially_covered: 0.5,
      weakly_implied: 0.25,
      missing: 0,
      contradicted: 0
    }
  },
  overall_quality_score: {
    weights: {
      accuracy_score: 0.7,
      tradeoff_coverage: 0.1,
      verification_quality: 0.1,
      relevance: 0.05,
      style_format_compliance: 0.05
    },
    verification_quality: {
      weights: {
        verification_point_coverage: 0.7,
        actionability_and_specificity: 0.3
      }
    }
  },
  value_added_score: {
    weights: {
      novel_information_correctness: 0.35,
      decision_usefulness: 0.3,
      actionability: 0.2,
      non_redundancy: 0.15
    }
  },
  pairwise_preference: {
    labels: ["candidate", "reference", "tie"],
    utility_weights: {
      correctness: 0.45,
      completeness: 0.2,
      decision_usefulness: 0.15,
      actionability: 0.1,
      relevance: 0.05,
      clarity_format: 0.05
    }
  },
  reporting_bands: {
    accuracy: {
      "90-100": "Strong reference alignment",
      "80-89.99": "Good alignment",
      "70-79.99": "Partial alignment",
      "50-69.99": "Weak alignment",
      "0-49.99": "Failed alignment"
    },
    overall_quality: {
      "90-100": "Excellent",
      "80-89.99": "Strong",
      "70-79.99": "Acceptable",
      "50-69.99": "Weak",
      "0-49.99": "Poor"
    },
    value_added: {
      "80-100": "Major additional value",
      "60-79.99": "Meaningful additional value",
      "40-59.99": "Moderate additional value",
      "20-39.99": "Small additional value",
      "0-19.99": "No meaningful additional value"
    }
  },
  style_format_compliance: {
    checks: {
      starts_with_yes_or_no: 0.22,
      decision_immediate: 0.18,
      correct_why_heading: 0.18,
      includes_tradeoffs: 0.16,
      includes_verify: 0.16,
      avoids_repeating_opening_decision: 0.10
    }
  }
};

function ftClampScore(value) {
  const n = Number(value);
  if (!Number.isFinite(n)) return null;
  return Math.max(0, Math.min(100, n));
}

function ftWeightedAverage(parts, weights) {
  let total = 0;
  let weightTotal = 0;
  Object.keys(weights || {}).forEach(key => {
    const score = ftClampScore(parts && parts[key]);
    const weight = Number(weights[key]);
    if (score !== null && Number.isFinite(weight) && weight > 0) {
      total += score * weight;
      weightTotal += weight;
    }
  });
  return weightTotal > 0 ? total / weightTotal : null;
}

function ftDecisionLabel(text) {
  const s = String(text || "").trim().toLowerCase();
  if (s.startsWith("yes.")) return "yes";
  if (s.startsWith("no.")) return "no";
  if (/\b(depends|conditional|it depends)\b/.test(s.slice(0, 220))) return "conditional";
  return "unclear";
}

function ftBandLabel(config, bandName, score) {
  const n = ftClampScore(score);
  const bands = config?.reporting_bands?.[bandName] || FT_DEFAULT_METRICS_CONFIG.reporting_bands[bandName] || {};
  if (n === null) return "Not scored";
  for (const key of Object.keys(bands)) {
    const parts = key.split("-").map(Number);
    if (parts.length === 2 && n >= parts[0] && n <= parts[1]) return bands[key];
  }
  return "Scored";
}

function ftStyleScore(answer, expected, config) {
  const text = String(answer || "").trim();
  const expectedLabel = ftDecisionLabel(expected);
  const candidateLabel = ftDecisionLabel(text);
  const expectedWhy = expectedLabel === "no" ? /Why no:/i : /Why yes:/i;
  const checks = {
    starts_with_yes_or_no: /^(Yes\.|No\.)/.test(text),
    decision_immediate: /^(Yes\.|No\.)\s+\S+/.test(text),
    correct_why_heading: expectedLabel === "unclear" ? /Why (yes|no):/i.test(text) : expectedWhy.test(text),
    includes_tradeoffs: /Trade-offs and exceptions:/i.test(text),
    includes_verify: /What to verify:/i.test(text),
    avoids_repeating_opening_decision: !/^((Yes\.|No\.)[^\n]{0,220})\n\s*\1/i.test(text)
  };
  const weights = config?.style_format_compliance?.checks || FT_DEFAULT_METRICS_CONFIG.style_format_compliance.checks;
  const score = ftWeightedAverage(Object.fromEntries(Object.keys(checks).map(k => [k, checks[k] ? 100 : 0])), weights);
  return { score: score == null ? 0 : score, checks, candidateLabel, expectedLabel };
}

function ftAnswerScore(answer, expected, config = FT_DEFAULT_METRICS_CONFIG) {
  const text = String(answer || "").trim();
  const style = ftStyleScore(text, expected, config);
  const referenceLabel = style.expectedLabel;
  const candidateLabel = style.candidateLabel;
  const explicitLabelCorrect = referenceLabel !== "unclear" && candidateLabel === referenceLabel;
  const intendedActionCorrect = explicitLabelCorrect;
  const decisionCorrectness = referenceLabel === "unclear"
    ? (candidateLabel === "unclear" ? 60 : 80)
    : explicitLabelCorrect ? 100 : candidateLabel === "unclear" ? 20 : 0;
  const internalConsistency = candidateLabel === "unclear"
    ? 50
    : ((candidateLabel === "yes" && /Why no:/i.test(text)) || (candidateLabel === "no" && /Why yes:/i.test(text))) ? 25 : 100;
  const relevance = text ? (text.length > 80 ? 90 : 65) : 0;
  const precheckScore = ftWeightedAverage({
    decision_correctness: decisionCorrectness,
    internal_consistency: internalConsistency,
    relevance,
    style_format_compliance: style.score
  }, {
    decision_correctness: 0.35,
    internal_consistency: 0.2,
    relevance: 0.15,
    style_format_compliance: 0.3
  });
  return {
    score: Math.round(precheckScore || 0),
    precheck_score: precheckScore,
    scores: {
      decision_correctness: decisionCorrectness,
      critical_point_recall: null,
      factual_correctness: null,
      internal_consistency: internalConsistency,
      accuracy_score: null,
      tradeoff_coverage: null,
      verification_quality: null,
      relevance,
      style_format_compliance: style.score,
      overall_quality_score: null,
      value_added_score: null,
      deterministic_precheck_score: precheckScore
    },
    decision_analysis: {
      reference_label: referenceLabel,
      candidate_label: candidateLabel,
      explicit_label_correct: explicitLabelCorrect,
      intended_action_correct: intendedActionCorrect
    },
    style_checks: style.checks,
    band: "Precheck only",
    note: "This is not the full rubric score. Full Accuracy, Overall Quality, Value-added, and Pairwise Preference require an LLM judge; null means not scored yet, not zero."
  };
}


function ftKeywordTokens(value) {
  return String(value || "")
    .toLowerCase()
    .replace(/[^a-z0-9%$.,\s-]/g, " ")
    .split(/\s+/)
    .map(token => token.trim())
    .filter(token => token.length >= 4 || /[%$]|\d/.test(token));
}

function ftPointCovered(answer, point) {
  const answerText = String(answer || "").toLowerCase();
  const tokens = [...new Set(ftKeywordTokens(point))]
    .filter(token => !/^(state|apply|identify|explain|under|that|this|with|from|rule|facts|point|answer)$/.test(token));
  if (!tokens.length) return false;
  const hits = tokens.filter(token => answerText.includes(token)).length;
  return hits >= Math.max(1, Math.ceil(tokens.length * 0.35));
}

function ftForbiddenHit(answer, claim) {
  const answerText = String(answer || "").toLowerCase();
  const tokens = [...new Set(ftKeywordTokens(claim))].filter(token => !/^(because|requires|every|only|under|that|this|with|from)$/.test(token));
  if (!tokens.length) return false;
  const hits = tokens.filter(token => answerText.includes(token)).length;
  return hits >= Math.max(2, Math.ceil(tokens.length * 0.5));
}

function ftGoldRubricScore(answer, gold, config) {
  const fallback = ftAnswerScore(answer, gold && gold.goldAnswer, config);
  if (!gold) return fallback;
  const expectedLabel = String(gold.goldLabel || ftDecisionLabel(gold.goldAnswer)).toLowerCase();
  const candidateLabel = ftDecisionLabel(answer);
  const universal = Boolean(gold.universal);
  const decisionCorrect = universal
    || (expectedLabel && expectedLabel !== "unclear" && candidateLabel === expectedLabel);
  const requiredPoints = Array.isArray(gold.requiredPoints) ? gold.requiredPoints : [];
  const requiredMetrics = Array.isArray(gold.requiredMetrics) ? gold.requiredMetrics : [];
  const forbiddenClaims = Array.isArray(gold.forbiddenClaims) ? gold.forbiddenClaims : [];
  const coveredPoints = requiredPoints.map(point => ({ point, covered: ftPointCovered(answer, point) }));
  const metricHits = requiredMetrics.map(metric => ({ metric, present: String(answer || "").toLowerCase().includes(String(metric || "").toLowerCase()) }));
  const forbiddenHits = forbiddenClaims.map(claim => ({ claim, hit: ftForbiddenHit(answer, claim) })).filter(item => item.hit);
  const pointScore = requiredPoints.length ? coveredPoints.filter(item => item.covered).length / requiredPoints.length * 100 : 100;
  const metricScore = requiredMetrics.length ? metricHits.filter(item => item.present).length / requiredMetrics.length * 100 : 100;
  const factualDiscipline = forbiddenHits.length ? 0 : 100;
  const decisionScore = decisionCorrect ? 100 : 0;
  const rubricScore = decisionCorrect
    ? (universal
        ? pointScore * 0.55 + metricScore * 0.15 + factualDiscipline * 0.30
        : decisionScore * 0.30 + pointScore * 0.40 + metricScore * 0.15 + factualDiscipline * 0.15)
    : 0;
  return {
    ...fallback,
    score: Math.round(rubricScore),
    band: "Gold rubric",
    goldActive: true,
    scores: {
      ...fallback.scores,
      decision_correctness: decisionScore,
      required_point_coverage: pointScore,
      required_metric_coverage: metricScore,
      factual_discipline: factualDiscipline,
      gold_rubric_score: rubricScore,
      overall_quality_score: rubricScore,
    },
    decision_analysis: {
      ...fallback.decision_analysis,
      reference_label: expectedLabel,
      candidate_label: candidateLabel,
      explicit_label_correct: decisionCorrect,
      intended_action_correct: decisionCorrect,
    },
    gold: {
      id: gold.id,
      label: gold.goldLabel,
      evidenceLocator: gold.evidenceLocator,
      coveredPoints,
      metricHits,
      forbiddenHits,
      requiredPointsTotal: requiredPoints.length,
      requiredPointsCovered: coveredPoints.filter(item => item.covered).length,
      requiredMetricsTotal: requiredMetrics.length,
      requiredMetricsCovered: metricHits.filter(item => item.present).length,
    },
    note: "Scored against the uploaded gold rubric using deterministic checks. Gold was not sent to the answering models.",
  };
}

function ftScoreWithGold(answer, expected, gold, config) {
  return gold ? ftGoldRubricScore(answer, gold, config) : ftAnswerScore(answer, expected, config);
}

// Built-in universal gold rubric: applies to every eval case without an
// upload. Focuses on substance (specifics, next steps) and discipline
// (no AI disclaimers) so both the deterministic scorer and the OpenAI
// judge grade the same contract.
const FT_BUILT_IN_GOLD_RUBRIC = {
  id: "builtin",
  universal: true,
  goldLabel: "",
  goldAnswer: "",
  requiredPoints: [
    "directly answers the question with a clear answer or recommendation",
    "includes specific details such as prices, dates, warranty terms, or model names",
    "ends with a concrete next step or helpful guidance",
  ],
  requiredMetrics: [],
  bonusDepth: [],
  forbiddenClaims: [
    "as an AI language model",
    "I don't have access to that information",
    "I cannot provide specific details",
  ],
}

// Comprehensive LLM judge metrics. The judge grades every answer on each
// metric at one of three levels; the rubric dialog shows the definitions
// and dots the fine-tuned model's most common level across judged cases.
const FT_JUDGE_METRICS = [
  {
    key: "accuracy", name: "Accuracy",
    levels: {
      excellent: "Every statement is factually correct and grounded in the knowledge base. Numbers, names, and claims all match the source material, with nothing invented or exaggerated.",
      insufficient: "Contains at least one wrong, invented, or unverifiable fact, or omits information essential to answering correctly. The customer could be misled by what is stated.",
    },
  },
  {
    key: "completeness", name: "Completeness",
    levels: {
      excellent: "Addresses every part of the customer's question directly, covering all the key points needed to act on the answer. Nothing important is left out or deferred without reason.",
      insufficient: "Answers only part of the question or misses its core point entirely. Key information the customer needs is absent or the reply drifts off-topic.",
    },
  },
  {
    key: "specificity", name: "Specificity",
    levels: {
      excellent: "Uses concrete details throughout - exact prices, dates, model names, warranty terms, and measurable quantities. The answer could not apply to a different product or situation.",
      insufficient: "Relies on vague, generic phrasing that could describe almost anything. Lacks the numbers, names, or concrete terms the customer actually needs.",
    },
  },
  {
    key: "actionability", name: "Actionability",
    levels: {
      excellent: "Ends with a clear next step or explicit recommendation - exactly what to do, when, and how. The customer can act immediately without asking again.",
      insufficient: "Leaves the customer without guidance - a dead end. Any direction given is too vague to act on without another round of questions.",
    },
  },
  {
    key: "tone", name: "Tone & brand voice",
    levels: {
      excellent: "Sounds warm, professional, and human - phrasing matches the brand voice and reads like a knowledgeable person helping, not a script.",
      insufficient: "Comes across as blunt, robotic, bureaucratic, or off-brand. The phrasing would feel wrong to a customer reading it.",
    },
  },
  {
    key: "discipline", name: "Discipline",
    levels: {
      excellent: "Owns the answer confidently and directly - commits to helping and states things plainly, with no hedging and no disclaimers about being an AI.",
      insufficient: "Hedges, refuses, or deflects - AI disclaimers, 'I don't have that information', or sending the customer elsewhere instead of helping.",
    },
  },
];
// The rubric lives in Firestore; the constant above is only the fallback
// until it loads (or if the fetch fails).
let FT_JUDGE_METRICS_REMOTE = null;
const FT_JUDGE_METRICS_LIVE = () => (Array.isArray(FT_JUDGE_METRICS_REMOTE) && FT_JUDGE_METRICS_REMOTE.length ? FT_JUDGE_METRICS_REMOTE : FT_JUDGE_METRICS);
window.FT_JUDGE_METRICS_LIVE = FT_JUDGE_METRICS_LIVE;
window.FT_JUDGE_SET_REMOTE_METRICS = (metrics) => { FT_JUDGE_METRICS_REMOTE = metrics; };
function FineTuneEvalModal({ job, ctx, onClose }) {
  const { user: authUser } = useFirebaseAuth();
  const [, setRubricTick] = React.useState(0);
  React.useEffect(() => {
    if (!authUser) return undefined;
    let cancelled = false;
    window.ModelOSIntegrations.getJudgeRubric(authUser)
      .then(rubric => {
        if (cancelled || !rubric || !Array.isArray(rubric.metrics) || !rubric.metrics.length) return;
        window.FT_JUDGE_SET_REMOTE_METRICS(rubric.metrics);
        setRubricTick(t => t + 1);
      })
      .catch(() => {});
    return () => { cancelled = true; };
  }, [authUser?.uid]);
  const [rows, setRows] = React.useState([]);
  const [running, setRunning] = React.useState(false);
  const [stage, setStage] = React.useState("Enter your API key and run to compare this fine-tune against the frontier model.");
  const [servingPair, setServingPair] = React.useState(null);
  const [metricsConfig, setMetricsConfig] = React.useState(FT_DEFAULT_METRICS_CONFIG);
  const goldRubric = [FT_BUILT_IN_GOLD_RUBRIC];
  const useRag = Boolean(ctx && ctx.activeAgent && ctx.activeAgent.modelId);
  const [provider, setProvider] = React.useState("openai");
  const [apiKey, setApiKey] = React.useState("");
  const [referenceModel, setReferenceModel] = React.useState("gpt-4o-mini");
  const [evalResult, setEvalResult] = React.useState(null);
  // Latest evaluation feedback (recommendation) for this eval's version.
  const [evalFeedback, setEvalFeedback] = React.useState(null);
  React.useEffect(() => {
    const versionId = evalResult && evalResult.dataVersionId;
    if (!authUser || !versionId || !evalResult || evalResult.status !== "completed") { setEvalFeedback(null); return undefined; }
    let cancelled = false;
    window.ModelOSIntegrations.listDataRecommendations(versionId, authUser)
      .then(recs => {
        if (cancelled) return;
        const list = recs || [];
        const match = list.find(r => r.evalJobId === evalResult.evalJobId) || list[0] || null;
        setEvalFeedback(match && match.text ? match : null);
      })
      .catch(() => { if (!cancelled) setEvalFeedback(null); });
    return () => { cancelled = true; };
  }, [authUser && authUser.uid, evalResult && evalResult.evalJobId, evalResult && evalResult.status, evalResult && evalResult.dataVersionId]);
  // Resolve the readable name (v1.1.1.2) for the eval's dataset version.
  const [versionName, setVersionName] = React.useState(null);
  React.useEffect(() => {
    const versionId = evalResult && evalResult.dataVersionId;
    const modelId = evalResult && evalResult.ragModelId;
    if (!authUser || !versionId || !modelId) { setVersionName(null); return undefined; }
    let cancelled = false;
    window.ModelOSIntegrations.listDataVersions(modelId, authUser)
      .then(versions => {
        if (cancelled) return;
        const match = (versions || []).find(v => v.versionId === versionId);
        setVersionName(match && match.name ? match.name : null);
      })
      .catch(() => { if (!cancelled) setVersionName(null); });
    return () => { cancelled = true; };
  }, [authUser && authUser.uid, evalResult && evalResult.dataVersionId, evalResult && evalResult.ragModelId]);
  const evalAbortRef = React.useRef(null);
  const evalStreamingRef = React.useRef(false); // true while a case's answers stream live
  const [evalRunning, setEvalRunning] = React.useState(false);
  const [showSpeedup, setShowSpeedup] = React.useState(false);
  const [showRubric, setShowRubric] = React.useState(false);
  const [showVersionJump, setShowVersionJump] = React.useState(false);
  const [answerMode, setAnswerMode] = React.useState("concise"); // concise = one pass (default), verbose = two
  const [modalBig, setModalBig] = React.useState(true);
  const [error, setError] = React.useState(null);
  const [onlyFineWins, setOnlyFineWins] = React.useState(false);
  const [sortBy, setSortBy] = React.useState("caseId"); // caseId, fineScoreAsc, fineScoreDesc
  const progressPollRef = React.useRef(null);


  React.useEffect(() => {
    let cancelled = false;
    if (!job || !job.jobId || !window.FineTuneApi || !window.FineTuneApi.evaluateLatest) return undefined;
    window.FineTuneApi.evaluateLatest(job.jobId).then(result => {
      if (cancelled || !result || !result.job) return;
      applyEvalProgress(result.job);
    }).catch(() => {});
    return () => { cancelled = true; };
  }, [job && job.jobId]);

  React.useEffect(() => {
    return () => {
      if (progressPollRef.current) clearInterval(progressPollRef.current);
    };
  }, []);

  function stopProgressPoll() {
    if (progressPollRef.current) {
      clearInterval(progressPollRef.current);
      progressPollRef.current = null;
    }
  }

  function startProgressPoll(evalJobId) {
    stopProgressPoll();
    if (!evalJobId || !job || !job.jobId || !window.FineTuneApi || !window.FineTuneApi.evaluateLatest) return;
    progressPollRef.current = setInterval(() => {
      window.FineTuneApi.evaluateLatest(job.jobId).then(latest => {
        if (!latest || !latest.job || latest.job.evalJobId !== evalJobId) return;
        // While a case streams live the server row is not written yet, so a
        // rebuild from server state would wipe the tokens already on screen.
        if (evalStreamingRef.current) return;
        applyEvalProgress(latest.job);
        if (latest.job.status === "completed" || latest.job.status === "failed") stopProgressPoll();
      }).catch(() => {});
    }, 4000);
  }

  async function waitReady(modelId) {
    for (let i = 0; i < 90; i += 1) {
      const status = await window.FineTuneApi.status({ model: modelId }).catch(() => null);
      if (status && status.ready) return status;
      setStage((status && status.label ? status.label : "Starting GPU machine") + "... preparing one GPU machine for base + fine-tune.");
      await new Promise(resolve => setTimeout(resolve, 10000));
    }
    throw new Error("The GPU machine did not become ready in time. Please retry in a few minutes.");
  }


  async function run() {
    if (job.isOwner === false || (evalResult && evalResult.isOwner === false)) { setError("This shared evaluation is read-only."); return; }
    if (!apiKey.trim()) { setError("Enter an OpenAI or Anthropic API key to auto-generate evaluation questions."); return; }
    if (running) return;
    return runAuto();
  }

  function cleanEvalAnswer(value) {
    return String(value || "").replace(/\uFFFD/g, "").trim();
  }
  function applyEvalProgress(result) {
    setEvalResult(result);
    setServingPair({ baseModel: result.referenceModel || referenceModel, fineModel: result.fineModel || "Preparing..." });
    setStage(result.status === "completed" ? "" : (result.stage || "Evaluating...") + (result.progress != null ? " (" + Math.round(result.progress) + "%)" : ""));
    const mapped = (result.rows || []).map((item, index) => {
      const gold = ftGoldForCase(item, goldRubric);
      const expected = (gold && gold.goldAnswer) || item.expected || item.referenceAnswer || "";
      const baseScore = item.referenceAnswer && expected ? ftScoreWithGold(item.referenceAnswer, expected, gold, metricsConfig) : null;
      const fineScore = item.fineAnswer && expected ? ftScoreWithGold(item.fineAnswer, expected, gold, metricsConfig) : null;
      const judge = item.judge || null;
      const judgedBaseScore = judge && Number.isFinite(Number(judge.baseScore)) ? { ...(baseScore || {}), score: Number(judge.baseScore), band: "OpenAI judge", judge } : baseScore;
      const judgedFineScore = judge && Number.isFinite(Number(judge.fineScore)) ? { ...(fineScore || {}), score: Number(judge.fineScore), band: "OpenAI judge", judge } : fineScore;
      return {
        caseId: item.caseId || (index + 1),
        type: item.type,
        input: item.input,
        expected,
        gold,
        baseAnswer: cleanEvalAnswer(item.referenceAnswer),
        fineAnswer: cleanEvalAnswer(item.fineAnswer),
        baseScore: judgedBaseScore,
        fineScore: judgedFineScore,
        judge,
        judgeError: item.judgeError || null,
        error: item.error || null,
        rationale: item.rationale,
        referenceMeta: item.reference,
        fineMeta: item.fineTune,
        rag: item.rag,
        ragError: item.ragError
      };
    });
    setRows(mapped);
  }

  async function runAuto() {
    if (running) return;
    setRunning(true);
    setRows([]);
    setEvalResult(null);
    setError(null);
    setServingPair(null);
    try {
      setStage("Creating evaluation job...");
      const activeAgent = ctx && ctx.activeAgent;
      const payload = { provider, referenceModel: referenceModel.trim() || undefined, goldRubric, ragModelId: useRag && activeAgent && activeAgent.modelId, ragModelName: useRag && activeAgent && activeAgent.name };
      const started = await window.FineTuneApi.evaluateStart(job.jobId, payload);
      applyEvalProgress(started);
      startProgressPoll(started.evalJobId);
      let current = started;
      const evalController = new AbortController();
      evalAbortRef.current = evalController;
      setEvalRunning(true);
      const evalPayload = {
        apiKey: apiKey.trim(),
        provider,
        referenceModel: referenceModel.trim() || undefined,
        judgeModel: provider === "openai" ? (referenceModel.trim() || "gpt-4o-mini") : "gpt-4o-mini",
        verbose: answerMode === "verbose"
      };
      for (let i = 0; i < 130; i += 1) {
        if (current.status === "completed" || current.status === "failed") break;
        if (evalController.signal.aborted) { setStage("Evaluation stopped."); break; }
        try {
          const streamResult = await window.FineTuneApi.evaluateStepStream(job.jobId, current.evalJobId, evalPayload, {
            stage: (data) => setStage((data.stage || "Evaluating...") + (data.progress != null ? ` (${Math.round(data.progress)}%)` : "")),
            case_start: (data) => {
              evalStreamingRef.current = true;
              setRows(prev => {
                const next = prev.slice();
                while (next.length <= data.index) next.push({ caseId: `case-${data.index + 1}`, input: data.input || "", baseAnswer: "", fineAnswer: "", error: null });
                return next;
              });
              setStage(`Answering case ${data.index + 1} of ${data.total}...`);
            },
            reference_delta: (data) => {
              setRows(prev => prev.map((r, idx) => idx === data.index ? { ...r, baseAnswer: data.text } : r));
            },
            fine_delta: (data) => {
              setRows(prev => prev.map((r, idx) => idx === data.index ? { ...r, fineAnswer: data.text } : r));
            },
            case_done: (data) => {
              if (data.row) {
                setRows(prev => prev.map((r, idx) => idx === data.index ? {
                  ...r, baseAnswer: cleanEvalAnswer(data.row.referenceAnswer), fineAnswer: cleanEvalAnswer(data.row.fineAnswer), error: data.row.error || null
                } : r));
              }
              setStage(`${data.completed}/${data.total} answered`);
            },
            judging_start: (data) => {
              setStage(`Performing LLM-as-a-Judge evaluation for case ${data.index + 1} of ${data.total}...`);
            },
            judge_done: (data) => {
              evalStreamingRef.current = false; // judged row is written; poller may rebuild again
              setRows(prev => prev.map((r, idx) => idx === data.index ? { ...r, judge: data.judge || r.judge } : r));
            },
            error: (data) => { setError(data.message || "Evaluation step failed."); },
          }, evalController.signal);
          evalStreamingRef.current = false;
          // After the stream, fetch the latest state for scoring/metadata.
          const latest = await window.FineTuneApi.evaluateLatest(job.jobId).catch(() => null);
          if (latest && latest.job && latest.job.evalJobId === current.evalJobId) {
            current = latest.job;
          } else if (streamResult && streamResult.status) {
            current = { ...current, ...streamResult };
          }
        } catch (stepError) {
          evalStreamingRef.current = false;
          if (stepError.name === "AbortError") { setStage("Evaluation stopped."); break; }
          const latest = await window.FineTuneApi.evaluateLatest(job.jobId).catch(() => null);
          if (latest && latest.job && latest.job.evalJobId === current.evalJobId) {
            current = latest.job;
            applyEvalProgress(current);
            if (current.status === "completed" || current.status === "failed") break;
            setStage((current.stage || "Evaluation still running") + ". Network recovered; continuing...");
            await new Promise(resolve => setTimeout(resolve, 1600));
            continue;
          }
          throw stepError;
        }
        applyEvalProgress(current);
        if (current.status === "completed" || current.status === "failed") break;
        await new Promise(resolve => setTimeout(resolve, 800));
      }
      if (current.status === "failed") throw new Error(current.error || "Evaluation failed.");
      if (current.status !== "completed") throw new Error("Evaluation is still running. Please click Generate + evaluate again to continue.");
      setStage("");
    } catch (e) {
      setError(e.message || "Could not run evaluation.");
      setStage("Evaluation stopped.");
    } finally {
      stopProgressPoll();
      setRunning(false);
      setEvalRunning(false);
      evalStreamingRef.current = false;
      evalAbortRef.current = null;
    }
  }
  async function clearEvaluation() {
    if (running || job.isOwner === false || (evalResult && evalResult.isOwner === false)) return;
    const current = evalResult;
    setError(null);
    try {
      if (current && current.evalJobId && window.FineTuneApi && window.FineTuneApi.evaluateClear) {
        await window.FineTuneApi.evaluateClear(job.jobId, current.evalJobId);
      }
      setRows([]);
      setEvalResult(null);
      setServingPair(null);
      setStage("Evaluation cleared. Generate a new run when ready.");
    } catch (e) {
      setError(e.message || "Could not clear evaluation.");
    }
  }

  const completed = rows.filter(r => r.fineAnswer).length;
  const unavailable = rows.filter(r => r.error && !r.fineAnswer).length;
  function fineTuneBeatsBase(row) {
    if (row && row.judge && row.judge.winner) return row.judge.winner === "fine";
    return Boolean(row && row.fineScore && row.baseScore && Number(row.fineScore.score) > Number(row.baseScore.score));
  }
  const filteredRows = onlyFineWins ? rows.filter(fineTuneBeatsBase) : rows;
  const visibleRows = [...filteredRows].sort((a, b) => {
    if (sortBy === "fineScoreDesc") {
      const aScore = a.fineScore && a.fineScore.score != null ? Number(a.fineScore.score) : -1;
      const bScore = b.fineScore && b.fineScore.score != null ? Number(b.fineScore.score) : -1;
      return bScore - aScore; // highest first
    }
    if (sortBy === "fineScoreAsc") {
      const aScore = a.fineScore && a.fineScore.score != null ? Number(a.fineScore.score) : 101;
      const bScore = b.fineScore && b.fineScore.score != null ? Number(b.fineScore.score) : 101;
      return aScore - bScore; // lowest first
    }
    // default: caseId order
    return 0;
  });
  // Per metric, the fine-tuned model's most common judge level across cases.
  const judgeLevelCounts = (() => {
    const judged = rows.filter(r => r.judge && r.judge.fineMetricLevels);
    if (!judged.length) return null;
    const out = {};
    for (const metric of FT_JUDGE_METRICS_LIVE()) {
      const counts = { excellent: 0, insufficient: 0 };
      for (const r of judged) {
        const level = String(r.judge.fineMetricLevels[metric.key] || "").toLowerCase();
        counts[level === "excellent" ? "excellent" : "insufficient"] += 1;
      }
      const total = counts.excellent + counts.insufficient;
      if (!total) continue;
      out[metric.key] = counts.insufficient > counts.excellent ? "insufficient" : "excellent";
    }
    return Object.keys(out).length ? out : null;
  })();
  const canModifyEval = job.isOwner !== false && (!evalResult || evalResult.isOwner !== false);

  return (
    <div className="scrim" role="presentation" onMouseDown={(e) => { if (e.target === e.currentTarget && !running && !evalRunning) onClose(); }}>
      <div className="modal" role="dialog" aria-modal="true" style={{ width: modalBig ? "95vw" : "min(1100px, 92vw)", maxWidth: "none", height: modalBig ? "95vh" : "80vh" }}>
        <div className="between" style={{ gap: 12, padding: "16px 18px", borderBottom: "1px solid var(--border)" }}>
          <div style={{ minWidth: 0 }}>
            <div className="h3">Evaluate {job.name}</div>
            <div className="caption">Samples questions from your evaluation set and compares performance between the frontier model and your fine-tuned model.</div>
          </div>
          <div className="row" style={{ gap: 8, flex: "none" }}>
            <button className="icon-btn" onClick={() => setModalBig(b => !b)} aria-label={modalBig ? "Shrink window" : "Expand window"} disabled={running} title={modalBig ? "Shrink the evaluation window" : "Expand to ~95% of the screen"}>{I(modalBig ? "chevD" : "chevR", { style: { width: 15, height: 15 } })}</button>
            <button className="icon-btn" onClick={onClose} aria-label="Close" disabled={running || evalRunning} title={evalRunning ? "Evaluation in progress - stop it before closing" : "Close"}>{I("x")}</button>
          </div>
        </div>

        {canModifyEval && (
        <div className="stack" style={{ gap: 12, padding: 18, borderBottom: "1px solid var(--border)" }}>
          <div className="row wrap" style={{ gap: 8, flexWrap: "wrap", alignItems: "center" }}>
            <span style={{ whiteSpace: "nowrap", fontSize: 14, fontWeight: 600, color: "var(--ink)" }}>Frontier model:</span>
            <select className="input" value={provider} onChange={e => { setProvider(e.target.value); setReferenceModel(e.target.value === "anthropic" ? "claude-3-5-sonnet-20241022" : "gpt-4o-mini"); }} disabled={running || !canModifyEval} style={{ width: 130 }}>
              <option value="openai">OpenAI</option>
              <option value="anthropic">Anthropic</option>
            </select>
            <input className="input" type="password" value={apiKey} onChange={e => setApiKey(e.target.value)} disabled={running || !canModifyEval} placeholder={provider === "anthropic" ? "Anthropic API key" : "OpenAI API key"} style={{ width: 220 }} />
            <select className="input" value={answerMode} onChange={e => setAnswerMode(e.target.value)} disabled={running || !canModifyEval} style={{ width: 118 }} title="Concise: one inference pass per fine-tuned answer. Verbose: base answer plus an orthogonal supplement pass.">
              <option value="verbose">Verbose</option>
              <option value="concise">Concise</option>
            </select>
            <button className="badge badge-good" style={{ cursor: "pointer" }} onClick={() => setShowRubric(true)} title="Show the built-in scoring rubric">{I("target", { style: { width: 11, height: 11 } })} LLM-as-a-Judge Rubric</button>
            {evalRunning && <button className="btn btn-ghost btn-sm" style={{ color: "var(--bad)" }} onClick={() => { evalAbortRef.current?.abort(); }} title="Stop the evaluation">{I("x")} Stop</button>}
            <button className="btn btn-secondary" onClick={clearEvaluation} disabled={running || !evalResult || !canModifyEval}>{I("x")} Clear</button>
            <button className="btn btn-primary" onClick={run} disabled={running || !canModifyEval || !apiKey.trim()}>{running ? <span className="spin" style={{ display: "inline-flex" }}>{I("refresh")}</span> : I("gauge")} Start eval</button>
            <button className="btn btn-ghost btn-sm" disabled={running || evalRunning} title={running || evalRunning ? "Stop the evaluation before leaving" : "Open the Training Data Studio"} onClick={() => {
              try {
                if (evalResult && evalResult.dataVersionId && authUser && authUser.uid) {
                  window.ModelOSUserStorage.save(authUser.uid, "studioJumpVersion", { versionId: evalResult.dataVersionId, modelId: evalResult.ragModelId || null, at: Date.now() });
                }
              } catch {}
              onClose && onClose();
              ctx.setView("conversations");
            }}>{I("message", { style: { width: 13, height: 13 } })} Training Data Studio</button>
          </div>
          {stage && <div className="caption">{stage}</div>}
          {servingPair && <div className="row" style={{ gap: 8, flexWrap: "wrap" }}><span className="badge badge-good">Evaluation progress</span><span className="caption">Fine-tuned model: {servingPair.fineModel}.</span></div>}
          {evalResult && (evalResult.ragModelId || evalResult.dataVersionId) && (
            <div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
              {evalResult.ragModelId && <span className="badge badge-neutral" title={evalResult.ragModelId}>{I("bot", { style: { width: 11, height: 11 } })} Chatbot: {evalResult.ragModelName || evalResult.ragModelId}</span>}
              {evalResult.dataVersionId && (
                <button className="badge badge-neutral" style={{ cursor: "pointer" }} onClick={() => setShowVersionJump(true)} title="Open this dataset version in the Training Data Studio">
                  {I("layers", { style: { width: 11, height: 11 } })} Dataset version: {versionName || evalResult.dataVersionId}
                </button>
              )}
            </div>
          )}
          {evalResult && evalResult.progress != null && <div style={{ height: 6, background: "var(--surface-2)", borderRadius: 999, overflow: "hidden" }}><div style={{ width: Math.max(2, Math.min(100, evalResult.progress)) + "%", height: "100%", background: "var(--primary)", transition: "width .25s ease" }} /></div>}
          {rows.length > 0 && (
            <div className="row between" style={{ gap: 12 }}>
              <div className="caption">Fine-tuned answers {completed}/{rows.length}{unavailable ? " - unavailable " + unavailable : ""}.</div>
              <div className="row" style={{ gap: 8 }}>
                <label className="caption" style={{ whiteSpace: "nowrap" }}>Sort by:</label>
                <select value={sortBy} onChange={e => setSortBy(e.target.value)} style={{ fontSize: 12, padding: "4px 8px", borderRadius: 4, border: "1px solid var(--border)", background: "var(--surface)" }}>
                  <option value="caseId">Case order</option>
                  <option value="fineScoreDesc">FT score (high → low)</option>
                  <option value="fineScoreAsc">FT score (low → high)</option>
                </select>
              </div>
            </div>
          )}
          {error && <div className="row" style={{ gap: 8, color: "var(--bad)", fontSize: 13 }}>{I("alert", { style: { width: 15, height: 15 } })}<span>{error}</span></div>}
        </div>
        )}

        <div className="scroll-y" style={{ padding: 18, background: "var(--bg)", height: "100%" }}>
          {!rows.length && (
            <div className="card card-pad stack" style={{ gap: 8, alignItems: "center", textAlign: "center", color: "var(--ink-3)", marginTop: 70 }}>
              <span>{I("target", { style: { width: 28, height: 28 } })}</span>
              <div className="h3" style={{ color: "var(--ink-2)" }}>{canModifyEval ? "Upload questions or enter an API key" : "Loading shared evaluation"}</div>
              <div className="caption" style={{ maxWidth: 560 }}>{canModifyEval ? "Progress appears here as the backend writes reference answers, then starts the GPU for your model's answers." : "Results will appear here."}</div>
            </div>
          )}
          {rows.length > 0 && !visibleRows.length && (
            <div className="card card-pad stack" style={{ gap: 8, alignItems: "center", textAlign: "center", color: "var(--ink-3)", marginTop: 70 }}>
              <span>{I("target", { style: { width: 28, height: 28 } })}</span>
              <div className="h3" style={{ color: "var(--ink-2)" }}>No scored cases yet</div>
            </div>
          )}
          <div className="stack" style={{ gap: 14 }}>
            {visibleRows.map((row, rowIdx) => (
              <div key={row.caseId} className="card card-pad stack" style={{ gap: 12 }}>
                <div className="between wrap" style={{ gap: 8 }}>
                  <div className="h3">Case {row.caseId}</div>
                </div>
                <div className="caption" style={{ color: "var(--ink-2)", whiteSpace: "pre-wrap" }}>{row.input}</div>

                <div style={{ display: "grid", gridTemplateColumns: "repeat(2,minmax(0,1fr))", gap: 12 }}>
                  <div className="stack" style={{ gap: 6 }}>
                    <div className="stack" style={{ gap: 4 }}>
                      <div><b>Reference answer from frontier model</b></div>
                      <div className="row" style={{ gap: 7, flexWrap: "wrap", alignItems: "center" }}>
                        <span className="badge badge-bad" title="Accessed by renting a third-party API — usage is metered and controlled by the provider">Rental API</span>
                        <span className="badge badge-bad" title="Your conversations and data are exposed to the provider and may be retained">Data Leakage</span>
                        <span className="badge badge-bad" title="Conversations are sent to a third-party API">Privacy ----</span>
                        <span className="badge badge-bad" title="Relative cost per answer — premium frontier API pricing">Cost $$$$$</span>
                      </div>
                    </div>
                    <div className="bubble bot" style={{ whiteSpace: "pre-wrap", maxWidth: "none" }}>{row.baseAnswer || (row.error ? "" : "Waiting...")}</div>
                  </div>
                  <div className="stack" style={{ gap: 6 }}>
                    <div className="stack" style={{ gap: 4 }}>
                      <div><b>Your fine-tuned model</b></div>
                      <div className="row between" style={{ gap: 7, flexWrap: "wrap", alignItems: "center" }}>
                        <span className="row" style={{ gap: 7, flexWrap: "wrap", alignItems: "center" }}>
                          <span className="badge badge-good" title="You own this model and its weights outright">Owned</span>
                          <span className="badge badge-good" title="Runs on your own dedicated infrastructure — data never leaves it">Privacy ++++</span>
                          <span className="badge badge-info" title="Relative cost per answer — your own efficient model">Cost $</span>
                        </span>
                        <span className="row" style={{ gap: 7, flexWrap: "wrap", alignItems: "center" }}>
                          <span className="badge badge-warn" title="Currently serving on a shared low-end GPU" style={{ whiteSpace: "nowrap" }}>∞ Inference on Low-end GPU</span>
                          <button className="btn btn-ghost btn-sm" onClick={() => setShowSpeedup(true)} title="Accelerate compute on your fine-tuned model">
                            {I("bolt", { style: { width: 12, height: 12, color: "var(--accent)" } })} Accelerate inference
                          </button>
                        </span>
                      </div>
                    </div>
                    <div className="bubble bot" style={{ whiteSpace: "pre-wrap", maxWidth: "none", color: row.error && !row.fineAnswer ? "var(--ink-3)" : undefined }}>{row.fineAnswer || (row.error ? "Fine-tuned model did not return an answer for this case." : "Waiting...")}</div>
                  </div>
                </div>
                {row.fineScore && row.baseScore && !row.judge && evalRunning && (
                  <div className="caption row" style={{ gap: 7, color: "var(--ink-3)" }}>
                    <span className="spin" style={{ display: "inline-flex" }}>{I("refresh", { style: { width: 13, height: 13 } })}</span>
                    Performing LLM-as-a-Judge evaluation...
                  </div>
                )}
                {row.fineScore && row.baseScore && row.judge && (
                  <details>
                    <summary className="caption" style={{ cursor: "pointer", fontWeight: 600 }}>Detailed Comparison Analysis</summary>
                    <div className="card" style={{ overflow: "hidden", marginTop: 8 }}>
                      <table className="tbl">
                        <thead><tr>
                          <th>Rubric</th>
                          <th style={{ textAlign: "center", width: "22%" }}>Frontier</th>
                          <th style={{ textAlign: "center", width: "22%" }}>Fine-tuned</th>
                        </tr></thead>
                        <tbody>
                          <tr>
                            <td><b>LLM-as-a-Judge score</b></td>
                            <td className="tnum" style={{ textAlign: "center" }}>{Math.round(row.baseScore.score)}/100</td>
                            <td className="tnum" style={{ textAlign: "center" }}>{Math.round(row.fineScore.score)}/100</td>
                          </tr>
                          {FT_JUDGE_METRICS_LIVE().map(metric => {
                            const baseLevel = String((row.judge && row.judge.baseMetricLevels || {})[metric.key] || "").toLowerCase();
                            const fineLevel = String((row.judge && row.judge.fineMetricLevels || {})[metric.key] || "").toLowerCase();
                            const metricNotes = row.judge && row.judge.metricNotes && row.judge.metricNotes[metric.key];
                            const levelBadge = level => level === "excellent"
                              ? <span className="badge badge-good">Excellent</span>
                              : <span className="badge badge-bad">Insufficient</span>;
                            return (
                              <tr key={metric.key}>
                                <td><b>{metric.name}</b></td>
                                <td style={{ textAlign: "center" }}>
                                  <div className="stack" style={{ gap: 4, alignItems: "center" }}>
                                    {levelBadge(baseLevel)}
                                    {metricNotes && metricNotes.base && <div className="caption" style={{ color: "var(--ink-3)", lineHeight: 1.45 }}>{metricNotes.base}</div>}
                                  </div>
                                </td>
                                <td style={{ textAlign: "center" }}>
                                  <div className="stack" style={{ gap: 4, alignItems: "center" }}>
                                    {levelBadge(fineLevel)}
                                    {metricNotes && metricNotes.fine && <div className="caption" style={{ color: "var(--ink-3)", lineHeight: 1.45 }}>{metricNotes.fine}</div>}
                                  </div>
                                </td>
                              </tr>
                            );
                          })}
                        </tbody>
                      </table>
                    </div>
                  </details>
                )}
                {row.judgeError && <div className="caption" style={{ color: "var(--bad)" }}>Judge scoring failed: {row.judgeError}</div>}
                {row.error && <div className="caption" style={{ color: "var(--ink-3)" }}>{row.error}</div>}
              </div>
            ))}
          </div>
          {/* Evaluation feedback moved to Training Data Studio */}
        </div>
      </div>

      {showVersionJump && evalResult && evalResult.dataVersionId && (
        <div className="scrim" role="presentation" onMouseDown={(e) => { if (e.target === e.currentTarget) setShowVersionJump(false); }}>
          <div className="modal" role="dialog" aria-modal="true" style={{ maxWidth: 440 }}>
            <div className="card-hd between" style={{ gap: 12 }}>
              <div className="row" style={{ gap: 10 }}>
                <span style={{ width: 34, height: 34, borderRadius: "var(--r-sm)", background: "var(--primary-soft)", color: "var(--ink)", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>{I("layers", { style: { width: 17, height: 17 } })}</span>
                <div>
                  <div className="h3">Open dataset version</div>
                  <div className="caption">Jump to the Training Data Studio with this version selected?</div>
                </div>
              </div>
              <button className="icon-btn" onClick={() => setShowVersionJump(false)} style={{ width: 32, height: 32 }}>{I("x", { style: { width: 15, height: 15 } })}</button>
            </div>
            <div className="card-pad stack" style={{ gap: 13 }}>
              <div className="caption" style={{ background: "var(--surface-2)", border: "1px solid var(--border)", borderRadius: 8, padding: 10 }}>
                <b className="mono">{versionName || evalResult.dataVersionId}</b>
                {evalResult.ragModelName || evalResult.ragModelId ? <span style={{ color: "var(--ink-3)" }}> · {evalResult.ragModelName || evalResult.ragModelId}</span> : null}
              </div>
              <div className="row" style={{ gap: 8, justifyContent: "flex-end" }}>
                <button className="btn btn-secondary" onClick={() => setShowVersionJump(false)}>Not now</button>
                <button className="btn btn-primary" onClick={() => {
                  try {
                    window.ModelOSUserStorage.save(authUser && authUser.uid ? authUser.uid : "", "studioJumpVersion", {
                      versionId: evalResult.dataVersionId,
                      modelId: evalResult.ragModelId || null,
                      at: Date.now(),
                    });
                  } catch {}
                  setShowVersionJump(false);
                  onClose && onClose();
                  ctx.setView("conversations");
                }}>{I("layers", { style: { width: 14, height: 14 } })} Jump to Training Data Studio</button>
              </div>
            </div>
          </div>
        </div>
      )}

      {showRubric && (
        <div className="scrim" role="presentation" onMouseDown={(e) => { if (e.target === e.currentTarget) setShowRubric(false); }}>
          <div className="modal" role="dialog" aria-modal="true" style={{ maxWidth: 980 }}>
            <div className="card-hd between" style={{ gap: 12 }}>
              <div className="row" style={{ gap: 10 }}>
                <span style={{ width: 34, height: 34, borderRadius: "var(--r-sm)", background: "var(--primary-soft)", color: "var(--ink)", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>{I("target", { style: { width: 17, height: 17 } })}</span>
                <div>
                  <div className="h3">LLM-as-a-Judge Rubric</div>
                  <div className="caption">
                    The LLM judge grades every answer on six metrics at two levels.
                    {judgeLevelCounts
                      ? " Dots mark the fine-tuned model's most common level across judged cases."
                      : " Run an evaluation to see the judge's selections."}
                  </div>
                </div>
              </div>
              <button className="icon-btn" onClick={() => setShowRubric(false)} style={{ width: 32, height: 32 }}>{I("x", { style: { width: 15, height: 15 } })}</button>
            </div>
            <div className="card-pad" style={{ paddingTop: 0 }}>
              <div className="card" style={{ overflow: "hidden" }}>
                <table className="tbl">
                  <thead><tr>
                    <th style={{ width: "30%" }}>Metric</th>
                    <th style={{ width: "35%", textAlign: "center" }}>Excellent</th>
                    <th style={{ width: "35%", textAlign: "center" }}>Insufficient</th>
                  </tr></thead>
                  <tbody>
                    {FT_JUDGE_METRICS_LIVE().map(metric => {
                      const modalLevel = judgeLevelCounts && judgeLevelCounts[metric.key];
                      return (
                        <tr key={metric.key}>
                          <td>
                            <div style={{ fontWeight: 700, marginBottom: 4 }}>{metric.name}</div>
                            <div className="caption">{metric.desc}</div>
                          </td>
                          {["excellent", "insufficient"].map(level => (
                            <td key={level} style={{ textAlign: "left", verticalAlign: "top", background: modalLevel === level ? "var(--surface-2)" : undefined }}>
                              <div className="row" style={{ gap: 7, alignItems: "flex-start", justifyContent: "flex-start" }}>
                                <span style={{ width: 8, height: 8, borderRadius: 999, flex: "none", marginTop: 4, display: "inline-block", background: modalLevel === level ? (level === "excellent" ? "var(--good)" : "var(--bad)") : "var(--border)" }} />
                                <span className="caption" style={{ textAlign: "left", lineHeight: 1.55 }}>{metric.levels[level]}</span>
                              </div>
                            </td>
                          ))}
                        </tr>
                      );
                    })}
                  </tbody>
                </table>
              </div>
            </div>
          </div>
        </div>
      )}

      {showSpeedup && (
        <div className="scrim" role="presentation" onMouseDown={(e) => { if (e.target === e.currentTarget) setShowSpeedup(false); }}>
          <div className="modal" role="dialog" aria-modal="true" style={{ maxWidth: 480 }}>
            <div className="card-hd between" style={{ gap: 12 }}>
              <div className="row" style={{ gap: 10 }}>
                <span style={{ width: 34, height: 34, borderRadius: "var(--r-sm)", background: "var(--primary-soft)", color: "var(--ink)", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>{I("bolt", { style: { width: 17, height: 17 } })}</span>
                <div>
                  <div className="h3">Accelerate model inference speed</div>
                  <div className="caption">Dedicated serving for your fine-tuned model.</div>
                </div>
              </div>
              <button className="icon-btn" onClick={() => setShowSpeedup(false)} style={{ width: 32, height: 32 }}>{I("x", { style: { width: 15, height: 15 } })}</button>
            </div>
            <div className="card-pad stack" style={{ gap: 13 }}>
              <div className="caption" style={{ lineHeight: 1.6 }}>
                Evaluations currently run on a shared low-end GPU that cold-starts for each run. The accelerated inference plan gives your fine-tuned model:
              </div>
              <div className="stack" style={{ gap: 8 }}>
                {[
                  "Always-on dedicated *high-end* GPU — no cold starts, answers start instantly",
                  "Quantized, serving-optimized deployment — up to *3x* lower latency",
                  "Higher throughput for concurrent users",
                  "Streaming responses in production with priority support",
                ].map(t => (
                  <div key={t} className="row" style={{ gap: 8, alignItems: "flex-start" }}>
                    {I("checkCircle", { style: { width: 14, height: 14, color: "var(--good)", flex: "none", marginTop: 2 } })}
                    <span className="caption">{t.split("*").map((part, i) => i % 2 === 1 ? <b key={i}>{part}</b> : part)}</span>
                  </div>
                ))}
              </div>
              <div className="row" style={{ gap: 8, justifyContent: "flex-end" }}>
                <button className="btn btn-secondary" onClick={() => setShowSpeedup(false)}>Not now</button>
                <a className="btn btn-primary" href="mailto:sales@modelos.technology?subject=Accelerate%20fine-tuned%20model%20inference" style={{ textDecoration: "none" }} onClick={() => setShowSpeedup(false)}>{I("chat", { style: { width: 14, height: 14 } })} Contact sales</a>
              </div>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

function MyJobs({ ctx, onNewJob }) {
  const [jobs, setJobs] = React.useState(null);   // null = not loaded yet
  const [error, setError] = React.useState(null);
  const [expanded, setExpanded] = React.useState(null);
  const [note, setNote] = React.useState(null);
  const [chatJob, setChatJob] = React.useState(null);
  const [evalJob, setEvalJob] = React.useState(null);
  const timer = React.useRef(null);
  const { user: authUser, loading: authLoading } = useFirebaseAuth();
  const [evalRagAgent, setEvalRagAgent] = React.useState(ctx && ctx.activeAgent ? ctx.activeAgent : null);

  React.useEffect(() => {
    if (ctx && ctx.activeAgent && ctx.activeAgent.modelId) {
      setEvalRagAgent(ctx.activeAgent);
      return;
    }
    if (authLoading || !authUser || authUser.isDemo || !window.ModelOSIntegrations || !window.ModelOSIntegrations.listAgents) return;
    let cancelled = false;
    window.ModelOSIntegrations.listAgents(authUser).then(agents => {
      if (cancelled) return;
      const preferred = (agents || []).find(agent => /nvidia\s+geforce\s+gpu\s+assistant/i.test(agent.name || "")) || (agents || [])[0] || null;
      if (!preferred) return;
      setEvalRagAgent({
        modelId: preferred.slug || preferred.id || preferred.modelId,
        name: preferred.name,
        ownerAddress: preferred.ownerAddress,
        systemPrompt: preferred.systemPrompt,
        openingMessage: preferred.openingMessage,
        voicePreset: preferred.voicePreset,
      });
    }).catch(() => {});
    return () => { cancelled = true; };
  }, [ctx && ctx.activeAgent && ctx.activeAgent.modelId, authLoading, authUser && authUser.uid]);

  const load = React.useCallback(() => {
    return window.FineTuneApi.listJobs()
      .then(r => { setJobs(r.jobs || []); setError(null); return r.jobs || []; })
      .catch(e => { setError(e.message || "Could not load jobs."); return []; });
  }, []);

  React.useEffect(() => {
    let alive = true;
    function tick() {
      load().then(list => {
        if (!alive) return;
        const anyActive = (list || []).some(j => window.FineTuneApi.isActive(j.status));
        if (anyActive && !timer.current) timer.current = setInterval(tick, 1500);
        if (!anyActive && timer.current) { clearInterval(timer.current); timer.current = null; }
      });
    }
    tick();
    return () => { alive = false; if (timer.current) { clearInterval(timer.current); timer.current = null; } };
  }, [load]);

  function flash(msg) { setNote(msg); setTimeout(() => setNote(n => (n === msg ? null : n)), 2600); }
  function cancel(job) { window.FineTuneApi.cancel(job.jobId).then(load).then(() => flash(`Canceled "${job.name}".`)); }
  function deleteJob(job) {
    if (!window.confirm(`Delete "${job.name}"? This removes the failed job record permanently.`)) return;
    window.FineTuneApi.deleteJob(job.jobId)
      .then(load)
      .then(() => flash(`Deleted "${job.name}".`))
      .catch(e => setError(e.message || "Could not delete this job."));
  }
  function use(job) { setChatJob(job); }
  function evaluate(job) { setEvalJob(job); }

  // loading skeleton (first load only)
  if (jobs === null && !error) {
    return (
      <div className="stack" style={{ gap: 14 }}>
        {[0, 1, 2].map(i => <div key={i} className="card card-pad" style={{ height: 92, opacity: 0.5 }}><div className="skeleton" style={{ height: 14, width: "40%", marginBottom: 10 }} /><div className="skeleton" style={{ height: 10, width: "70%" }} /></div>)}
      </div>
    );
  }
  if (error) {
    return (
      <div className="card card-pad between wrap" style={{ gap: 12, borderColor: "var(--bad-border)", background: "var(--bad-bg)" }}>
        <div className="row" style={{ gap: 10 }}><span style={{ color: "var(--bad)" }}>{I("alert", { style: { width: 18, height: 18 } })}</span><span style={{ fontSize: 13.5 }}>{error}</span></div>
        <button className="btn btn-secondary btn-sm" onClick={() => { setJobs(null); load(); }}>{I("refresh", { style: { width: 14, height: 14 } })} Retry</button>
      </div>
    );
  }
  if (!jobs.length) {
    return (
      <div className="card card-pad stack" style={{ gap: 12, alignItems: "center", textAlign: "center", padding: "48px 20px" }}>
        <span style={{ color: "var(--ink-3)" }}>{I("layers", { style: { width: 34, height: 34 } })}</span>
        <div className="h3">No training jobs yet</div>
        <div className="caption" style={{ maxWidth: 340 }}>Launch a fine-tune run to see it appear here with live progress.</div>
        <button className="btn btn-primary" onClick={onNewJob}>{I("plus", { style: { width: 15, height: 15 } })} New fine-tune job</button>
      </div>
    );
  }

  return (
    <div className="stack" style={{ gap: 14 }}>
      {chatJob && <FineTuneChatModal job={chatJob} knowledgeModelId={ctx.activeAgent?.modelId} onClose={() => setChatJob(null)} />}
      {evalJob && <FineTuneEvalModal job={evalJob} ctx={{ ...ctx, activeAgent: evalRagAgent }} onClose={() => setEvalJob(null)} />}
      {note && <div className="card card-pad row fu" style={{ gap: 10, background: "var(--good-bg)", borderColor: "var(--good-border)" }}><span style={{ color: "var(--good)" }}>{I("check", { style: { width: 16, height: 16 } })}</span><span style={{ fontSize: 13.5 }}>{note}</span></div>}
      {jobs.map(j => (
        <JobCard key={j.jobId} job={j}
          expanded={expanded === j.jobId} onToggle={() => setExpanded(x => x === j.jobId ? null : j.jobId)}
          onCancel={cancel} onDelete={deleteJob} onUse={use} onEvaluate={evaluate} />
      ))}
    </div>
  );
}

function FineTuneView({ ctx }) {
  const [tab, setTab] = React.useState("new");     // 'new' | 'jobs'
  const [flash, setFlash] = React.useState(null);
  const [launching, setLaunching] = React.useState(false);
  // Cutover: mock by default. Flip to the real /api/sft/* backend by setting
  // window.FINETUNE_USE_MOCK = false (optionally window.MODEL_OS_CONFIG.finetuneBaseUrl).
  React.useEffect(() => {
    const conf = window.MODEL_OS_CONFIG || {};
    const integ = window.ModelOSIntegrations && window.ModelOSIntegrations.load ? window.ModelOSIntegrations.load() : null;
    const hasExplicitMock = window.FINETUNE_USE_MOCK !== undefined || conf.finetuneUseMock !== undefined;
    const useMock = hasExplicitMock ? !(window.FINETUNE_USE_MOCK === false || conf.finetuneUseMock === false) : window.location.protocol === "file:";
    const baseUrl = conf.finetuneBaseUrl !== undefined ? conf.finetuneBaseUrl : "";
    window.FineTuneApi.configure({
      useMock,
      baseUrl,
      getToken: () => (window.firebaseAuth && window.firebaseAuth.currentUser ? window.firebaseAuth.currentUser.getIdToken() : Promise.resolve(null)),
    });
  }, []);

  async function launch(cfg) {
    setLaunching(true); setFlash(null);
    try {
      const uploadData = cfg.files && cfg.files.length
        ? await window.FineTuneApi.upload(cfg.files, { jobName: cfg.name, conversionMode: "llm" })
        : (cfg.approvedSelected && cfg.modelId
          ? await window.ModelOSIntegrations.prepareTrainingDataset(cfg.modelId, undefined, cfg.versionId)
          : null);
      const datasetS3Path = uploadData && (uploadData.datasetS3Path || uploadData.s3Path || uploadData.path);
      const isMock = window.FineTuneApi._config && window.FineTuneApi._config.useMock;
      if (!isMock && !datasetS3Path) throw new Error("Select the Approved Training Examples dataset (or upload a training file) before starting fine-tuning.");

      await window.FineTuneApi.launch({
        provider: "aws",
        jobName: cfg.name,
        name: cfg.name,
        datasetS3Path,
        versionId: (uploadData && uploadData.versionId) || cfg.versionId || null,
        evalS3Path: (uploadData && uploadData.evalS3Path) || null,
        baseModel: cfg.baseModel,
        params: cfg.params,
        conversionMode: "llm",
        files: (cfg.files || []).map(f => f.name)
      });
      setTab("jobs");
      setFlash({ kind: "good", text: `Fine-tune job "${cfg.name}" launched.` });
      setTimeout(() => setFlash(null), 3000);
    } catch (e) {
      setFlash({ kind: "bad", text: e.message || "Could not launch the job." });
    } finally {
      setLaunching(false);
    }
  }

  const TabBtn = ({ id, label }) => (
    <button className={cx("btn", "btn-sm", tab === id ? "btn-primary" : "btn-ghost")} onClick={() => setTab(id)}>{label}</button>
  );

  return (
    <div className="page page-wide">
      <PageHead eyebrow="Improve · Fine-Tuning Studio"
        title="Fine-tune a better model"
        sub="Upload data, pick a base model, launch a training job, and track it to completion.">
        <div className="row" style={{ gap: 6 }}><TabBtn id="new" label="New job" /><TabBtn id="jobs" label="My jobs" /></div>
      </PageHead>

      {flash && (
        <div className="card card-pad row fu" style={{ gap: 10, marginBottom: 16, background: flash.kind === "bad" ? "var(--bad-bg)" : "var(--good-bg)", borderColor: flash.kind === "bad" ? "var(--bad-border)" : "var(--good-border)" }}>
          <span style={{ color: flash.kind === "bad" ? "var(--bad)" : "var(--good)" }}>{I(flash.kind === "bad" ? "alert" : "checkCircle", { style: { width: 17, height: 17 } })}</span>
          <span style={{ fontSize: 13.5 }}>{flash.text}</span>
        </div>
      )}

      {tab === "new"
        ? <FineTuneSetup ctx={ctx} onStart={launch} launching={launching} />
        : <MyJobs ctx={ctx} onNewJob={() => setTab("new")} />}
    </div>
  );
}
window.MyJobs = MyJobs;
window.JobCard = JobCard;
window.FineTuneView = FineTuneView;










