/* ============================================================
   page-ml.jsx — Neural decision layer: shadow review + metrics
   Visual-first admin surface: every number gets a chart and a
   plain-language caption, plus a built-in "How it works" guide.
   ============================================================ */

const ML_HEADS = ['flag_checkin', 'uphold_strike', 'auto_approve_pto', 'nudge_effective', 'noshow_risk'];
const ML_HEAD_LABELS = {
  flag_checkin:     'Flag check-in',
  uphold_strike:    'Uphold strike',
  auto_approve_pto: 'Auto-approve PTO',
  nudge_effective:  'Nudge timing',
  noshow_risk:      'No-show risk',
};
const ML_HEAD_PLAIN = {
  flag_checkin:     'Would an admin want this check-in flagged?',
  uphold_strike:    'Would an admin let this strike stand?',
  auto_approve_pto: 'Would an admin approve this time-off request?',
  nudge_effective:  'Will a reminder right now actually work?',
  noshow_risk:      'Will this person miss check-in entirely today?',
};
// Fixed per-head colors (identity follows the entity, validated for dark surface)
const ML_COLORS = {
  flag_checkin:     '#3987e5',
  auto_approve_pto: '#199e70',
  nudge_effective:  '#c98500',
  noshow_risk:      '#9085e9',
  uphold_strike:    '#e66767',
};
const ML_SOURCE_LABELS = {
  auto_outcome:   'Self-labeled (reality)',
  admin_override: 'Admin actions',
  admin_feedback: 'Review thumbs',
  admin_confirm:  'Quiet confirmation',
};
const ML_SOURCE_COLORS = ['#3987e5', '#199e70', '#c98500', '#9085e9'];

function mlSurface() {
  try {
    const v = getComputedStyle(document.body).getPropertyValue('--card').trim();
    return v || '#101014';
  } catch (e) { return '#101014'; }
}

function mlInk() {
  // Chart.js defaults its legend/tick ink to dark gray — unreadable on the
  // dark theme. Use the page's own text color so both themes stay legible.
  try {
    const v = getComputedStyle(document.body).color;
    return v || '#e8e8e8';
  } catch (e) { return '#e8e8e8'; }
}

/* ---------- tiles ---------- */
function MLStatTile({ label, value, sub, color }) {
  return (
    <div className="card" style={{ padding: '10px 14px', minWidth: 140, flex: '1 1 140px' }}>
      <div className="muted" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.04em' }}>{label}</div>
      <div style={{ fontSize: 24, fontWeight: 700, color: color || 'inherit' }}>{value}</div>
      {sub ? <div className="muted" style={{ fontSize: 11 }}>{sub}</div> : null}
    </div>
  );
}

/* ---------- doughnut (Chart.js) ---------- */
function MLDoughnut({ title, caption, labels, values, colors, centerLabel }) {
  const ref = React.useRef(null);
  React.useEffect(() => {
    if (!window.Chart || !ref.current) return;
    const ctx = ref.current.getContext('2d');
    if (ref.current._chart) ref.current._chart.destroy();
    const total = values.reduce((a, b) => a + b, 0);
    ref.current._chart = new window.Chart(ctx, {
      type: 'doughnut',
      data: {
        labels,
        datasets: [{
          data: values,
          backgroundColor: colors,
          borderColor: mlSurface(),
          borderWidth: 2,            // 2px surface gap between segments
          hoverOffset: 6,
        }],
      },
      options: {
        responsive: true, maintainAspectRatio: false, cutout: '62%',
        plugins: {
          legend: {
            position: 'bottom',
            labels: {
              boxWidth: 10, boxHeight: 10, font: { size: 10 },
              color: mlInk(),
              generateLabels(chart) {
                const d = chart.data;
                return d.labels.map((l, i) => {
                  const v = d.datasets[0].data[i];
                  const pct = total ? Math.round(v / total * 100) : 0;
                  return {
                    text: `${l} — ${pct}%`,
                    fillStyle: d.datasets[0].backgroundColor[i],
                    fontColor: mlInk(),
                    strokeStyle: 'transparent', index: i,
                  };
                });
              },
            },
          },
          tooltip: {
            callbacks: {
              label: (c) => ` ${c.label}: ${c.raw} (${total ? Math.round(c.raw / total * 100) : 0}%)`,
            },
          },
        },
      },
    });
    return () => { if (ref.current?._chart) ref.current._chart.destroy(); };
  }, [JSON.stringify(labels), JSON.stringify(values)]);

  const total = values.reduce((a, b) => a + b, 0);
  return (
    <div className="card" style={{ padding: 12, flex: '1 1 260px', minWidth: 240 }}>
      <div style={{ fontWeight: 600, marginBottom: 2 }}>{title}</div>
      <div className="muted" style={{ fontSize: 11, marginBottom: 8, minHeight: 28 }}>{caption}</div>
      {total === 0
        ? <div className="muted" style={{ padding: 30, textAlign: 'center', fontSize: 12 }}>No data yet — this fills up as the bot works.</div>
        : (
          <div style={{ position: 'relative', height: 210 }}>
            <canvas ref={ref} />
            {centerLabel ? (
              <div style={{ position: 'absolute', top: '34%', left: 0, right: 0, textAlign: 'center', pointerEvents: 'none' }}>
                <div style={{ fontSize: 20, fontWeight: 700 }}>{total}</div>
                <div className="muted" style={{ fontSize: 10 }}>{centerLabel}</div>
              </div>
            ) : null}
          </div>
        )}
    </div>
  );
}

/* ---------- per-head quality bars (plain HTML — thin marks, direct labels) ---------- */
function MLQualityBars({ champion }) {
  const m = (champion && champion.metrics) || {};
  const rows = ML_HEADS.map(h => {
    const hm = m[h] || {};
    return { head: h, pr: hm.pr_auc, ece: hm.ece, skipped: hm.skipped || hm.pr_auc === undefined };
  });
  const grade = (pr) => pr >= 0.9 ? ['Excellent', '#0ca30c'] : pr >= 0.75 ? ['Good', '#0ca30c']
    : pr >= 0.6 ? ['Fair', '#fab219'] : ['Weak', '#ec835a'];
  return (
    <div className="card" style={{ padding: 12, flex: '1 1 300px', minWidth: 280 }}>
      <div style={{ fontWeight: 600, marginBottom: 2 }}>Model quality per decision type</div>
      <div className="muted" style={{ fontSize: 11, marginBottom: 10 }}>
        Score = how well the model separates right from wrong calls (100% is perfect, tested on data it never saw).
      </div>
      {rows.map(r => (
        <div key={r.head} style={{ marginBottom: 9 }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11.5, marginBottom: 2 }}>
            <span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: 2, background: ML_COLORS[r.head], marginRight: 6 }} />{ML_HEAD_LABELS[r.head]}</span>
            {r.skipped
              ? <span className="muted">learning — needs more labels</span>
              : <span style={{ color: grade(r.pr)[1], fontWeight: 600 }}>{grade(r.pr)[0]} · {(r.pr * 100).toFixed(0)}%</span>}
          </div>
          <div style={{ height: 6, borderRadius: 4, background: 'rgba(148,163,184,.15)', overflow: 'hidden' }}>
            <div style={{
              height: '100%', borderRadius: 4,
              width: r.skipped ? '0%' : `${Math.round(r.pr * 100)}%`,
              background: ML_COLORS[r.head], transition: 'width .4s',
            }} />
          </div>
        </div>
      ))}
      <div className="muted" style={{ fontSize: 10.5, marginTop: 6 }}>
        Heads without a score simply haven't collected enough real examples yet — they fill in automatically as admins approve, decline, and forgive.
      </div>
    </div>
  );
}

/* ---------- calibration ---------- */
function MLCalibrationChart({ head, metrics }) {
  const ref = React.useRef(null);
  const calib = ((metrics?.heads || {})[head] || {}).calibration || [];
  React.useEffect(() => {
    if (!window.Chart || !ref.current) return;
    const ctx = ref.current.getContext('2d');
    if (ref.current._chart) ref.current._chart.destroy();
    ref.current._chart = new window.Chart(ctx, {
      type: 'line',
      data: {
        labels: calib.map(c => c.bin),
        datasets: [
          {
            label: 'What actually happened',
            data: calib.map(c => c.observed),
            borderColor: ML_COLORS[head] || '#9085e9',
            backgroundColor: 'transparent',
            spanGaps: true, tension: 0.2, pointRadius: 4, borderWidth: 2,
          },
          {
            label: 'Perfectly honest model',
            data: calib.map(c => parseFloat(c.bin) + 0.05),
            borderColor: 'rgba(148,163,184,.55)',
            borderDash: [6, 4], pointRadius: 0, borderWidth: 2,
          },
        ],
      },
      options: {
        responsive: true, maintainAspectRatio: false,
        scales: {
          y: { min: 0, max: 1,
               ticks: { color: mlInk() },
               grid: { color: 'rgba(148,163,184,.12)' },
               title: { display: true, text: 'How often it came true', font: { size: 10 }, color: mlInk() } },
          x: { ticks: { color: mlInk() },
               grid: { color: 'rgba(148,163,184,.12)' },
               title: { display: true, text: 'What the model predicted', font: { size: 10 }, color: mlInk() } },
        },
        plugins: { legend: { labels: { boxWidth: 12, font: { size: 10 }, color: mlInk() } } },
      },
    });
    return () => { if (ref.current?._chart) ref.current._chart.destroy(); };
  }, [head, JSON.stringify(calib)]);
  const hasData = calib.some(c => c.n > 0);
  if (!hasData) return <div className="muted" style={{ padding: 20, textAlign: 'center', fontSize: 12 }}>
    Not enough labeled predictions for this decision type yet — the line appears as outcomes accumulate.
  </div>;
  return <div style={{ height: 200 }}><canvas ref={ref} /></div>;
}

/* ---------- the guide ---------- */
function MLGuideModal({ onClose }) {
  return (
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,.55)', zIndex: 1000,
      display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
    }} onClick={onClose}>
      <div className="card" style={{ maxWidth: 640, maxHeight: '84vh', overflowY: 'auto', padding: 20 }}
           onClick={e => e.stopPropagation()}>
        <div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
          <div style={{ fontSize: 17, fontWeight: 700 }}>🧠 How the neural layer works</div>
          <div style={{ flex: 1 }} />
          <button className="btn btn-ghost btn-xs" onClick={onClose}>✕ close</button>
        </div>

        <div style={{ fontSize: 13, lineHeight: 1.55 }}>
          <p><b>The purpose.</b> The bot makes the same small decisions hundreds of times — flag a late
          check-in? approve this PTO? nudge someone now? Those used to be fixed thresholds someone
          guessed once. The neural layer <i>learns</i> from what actually happens and from what admins
          actually decide, so decisions get more consistent, more personal, and more accurate over time.</p>

          <p><b>What it never does.</b> It never messages employees. It never issues strikes on its own —
          ever. And in the current <b>shadow</b> mode it changes nothing at all: the old rules make every
          decision, the model just writes down what <i>it</i> would have done so you can compare.</p>

          <p style={{ marginTop: 10 }}><b>The loop, in 5 steps:</b></p>
          <ol style={{ paddingLeft: 20, margin: '4px 0 10px' }}>
            <li><b>Observe</b> — every decision is logged with ~40 facts about the moment (usual sign-in time, recent pattern, day of week, team situation…).</li>
            <li><b>Learn</b> — twice a day (afternoon + evening) the bot retrains itself on everything logged so far. No button pressing needed.</li>
            <li><b>Score</b> — the live model attaches a probability to each new decision, e.g. "87% chance this person misses today".</li>
            <li><b>Verify</b> — reality labels the predictions (did they show up?), and admin approvals/reversals label the rest. The <i>Review queue</i> tab is where disagreements get settled — every 👍/👎 teaches the next model.</li>
            <li><b>Improve</b> — a new model that clearly beats the current one takes over automatically (only while in shadow; once the model can act, promotion becomes a human click again).</li>
          </ol>

          <p style={{ marginTop: 10 }}><b>What the numbers mean:</b></p>
          <ul style={{ paddingLeft: 20, margin: '4px 0 10px' }}>
            <li><b>Quality score</b> — how well the model separates correct calls from wrong ones on data it never trained on. 90%+ is excellent; 50% is a coin flip.</li>
            <li><b>Honesty check (calibration)</b> — if the solid line hugs the dashed line, "80% confident" really comes true ~80% of the time. That honesty is what would make probabilities safe to act on later.</li>
            <li><b>Agrees with rules</b> — how often model and current rules reach the same conclusion. High agreement plus better accuracy on the disagreements = the model has earned more trust.</li>
            <li><b>Truth collected</b> — the answer key gathered so far. More truth → smarter models. Most of it arrives automatically.</li>
          </ul>

          <p style={{ marginTop: 10 }}><b>The controls</b> live in <i>Bot Behavior → Neural</i>: the master mode
          (off / shadow / assist / auto), confidence thresholds, per-decision switches, and hard safety rails
          (never-DM list, max actions per person per week, new-employee protection, team-outage suppression).
          Everything is on-demand and reversible — the Audit log records every move.</p>

          <p className="muted" style={{ fontSize: 11.5, marginTop: 8 }}>
            Where this shows up elsewhere: attendance reports carry a one-line "🧠 attendance risks" summary,
            and managers/superadmin get a bi-weekly outlook DM. Employees receive nothing from the neural layer.
          </p>
        </div>
      </div>
    </div>
  );
}

/* ---------- page ---------- */
function MLPage() {
  const [metrics, setMetrics] = React.useState(null);
  const [decisions, setDecisions] = React.useState([]);
  const [models, setModels] = React.useState([]);
  const [tab, setTab] = React.useState('overview');
  const [headFilter, setHeadFilter] = React.useState('');
  const [calibHead, setCalibHead] = React.useState('noshow_risk');
  const [onlyDisagree, setOnlyDisagree] = React.useState(true);
  const [busy, setBusy] = React.useState(false);
  const [toast, setToast] = React.useState('');
  const [err, setErr] = React.useState('');
  const [guideOpen, setGuideOpen] = React.useState(false);

  const say = (m) => { setToast(m); setTimeout(() => setToast(''), 4000); };

  // Retry transient gateway errors (502/503/504) with backoff. Deploys restart
  // the single bot container for ~30-60s, during which nginx returns 502 — that
  // was surfacing as a raw "metrics: 502" the user had to dismiss. Now it rides
  // the restart out and self-heals instead.
  const fetchRetry = async (url, opts, tries = 6) => {
    for (let i = 0; i < tries; i++) {
      try {
        const r = await fetch(url, opts);
        if (r.ok) return r.json();
        if (![502, 503, 504].includes(r.status) || i === tries - 1) throw r.status;
      } catch (e) { if (i === tries - 1) throw e; }
      await new Promise(res => setTimeout(res, Math.min(1000 * (i + 1), 4000)));
    }
  };

  const loadAll = React.useCallback(() => {
    fetchRetry('/api/ml/metrics', { credentials: 'same-origin' })
      .then(d => { setMetrics(d); setErr(''); })
      .catch(e => setErr(typeof e === 'number'
        ? `Model service is restarting (${e}) — retrying automatically…`
        : 'metrics: ' + e));
    fetchRetry('/api/ml/models', { credentials: 'same-origin' })
      .then(d => setModels(d.models || [])).catch(() => {});
  }, []);

  const loadDecisions = React.useCallback(() => {
    const params = new URLSearchParams({ limit: '100' });
    if (headFilter) params.set('head', headFilter);
    if (onlyDisagree) params.set('disagree', 'true');
    fetch('/api/ml/decisions?' + params, { credentials: 'same-origin' })
      .then(r => r.ok ? r.json() : Promise.reject(r.status))
      .then(d => setDecisions(d.decisions || [])).catch(e => setErr('decisions: ' + e));
  }, [headFilter, onlyDisagree]);

  React.useEffect(() => { loadAll(); }, [loadAll]);
  // Keep trying while metrics haven't loaded (rides out a longer restart) —
  // stops as soon as they arrive.
  React.useEffect(() => {
    if (metrics) return;
    const t = setInterval(loadAll, 12000);
    return () => clearInterval(t);
  }, [metrics, loadAll]);
  React.useEffect(() => { if (tab === 'review') loadDecisions(); }, [tab, loadDecisions]);

  const feedback = (id, label) => {
    fetch('/api/ml/feedback', {
      method: 'POST', credentials: 'same-origin',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ decision_id: id, label }),
    }).then(r => r.ok ? r.json() : Promise.reject(r.status))
      .then(() => { say('Label saved — that trains the next model. 👌'); loadDecisions(); })
      .catch(e => setErr('feedback: ' + e));
  };

  const trainNow = () => {
    if (busy) return;
    setBusy(true); say('Training started — this also runs by itself twice a day.');
    fetch('/api/ml/train', { method: 'POST', credentials: 'same-origin' })
      .then(r => r.ok ? r.json() : Promise.reject(r.status))
      .then(d => {
        const s = d.summary || {};
        if (s.status === 'refused') say('Not enough labels yet: ' + (s.reason || ''));
        else say('Candidate ' + (s.version || '') + ' trained on ' + (s.n_total || 0) + ' examples.');
        loadAll();
      })
      .catch(e => setErr('train: ' + e))
      .finally(() => setBusy(false));
  };

  const promote = (version) => {
    if (!window.confirm('Promote ' + version + ' to champion? It starts scoring live decisions immediately (mode still governs whether it can act).')) return;
    fetch('/api/ml/models/' + encodeURIComponent(version) + '/promote', {
      method: 'POST', credentials: 'same-origin',
    }).then(r => r.ok ? r.json() : r.json().then(b => Promise.reject(b.detail || r.status)))
      .then(() => { say(version + ' is now the live model.'); loadAll(); })
      .catch(e => setErr('promote: ' + String(e)));
  };

  const heads = metrics?.heads || {};
  const totDecisions = ML_HEADS.reduce((a, h) => a + ((heads[h] || {}).decisions || 0), 0);
  const scoredHeads = ML_HEADS.filter(h => (heads[h] || {}).scored > 0);
  const avgAgree = scoredHeads.length
    ? scoredHeads.reduce((a, h) => a + (heads[h].agreement || 0), 0) / scoredHeads.length : null;
  const fmtPct = (v) => v === null || v === undefined ? '—' : Math.round(v * 100) + '%';

  const srcEntries = Object.entries(metrics?.label_sources || {});
  const champion = metrics?.champion;

  return (
    <div>
      {guideOpen ? <MLGuideModal onClose={() => setGuideOpen(false)} /> : null}
      {toast ? <div className="card" style={{ padding: 10, marginBottom: 10, borderColor: '#0ca30c', fontSize: 13 }}>{toast}</div> : null}
      {err ? <div className="card" style={{ padding: 10, marginBottom: 10, borderColor: '#d03b3b', fontSize: 13 }}>
        Error: {err} <button className="btn btn-ghost btn-xs" onClick={() => setErr('')}>dismiss</button>
      </div> : null}

      {/* header */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12, flexWrap: 'wrap' }}>
        <div style={{ display: 'flex', gap: 6 }}>
          {['overview', 'review', 'models'].map(t => (
            <button key={t} className={'btn btn-sm ' + (tab === t ? 'btn-primary' : 'btn-ghost')}
                    onClick={() => setTab(t)}>
              {t === 'overview' ? 'Overview' : t === 'review' ? 'Review queue' : 'Models'}
            </button>
          ))}
        </div>
        <button className="btn btn-ghost btn-sm" onClick={() => setGuideOpen(true)}
                title="Plain-language guide to what all of this means">
          ❓ How it works
        </button>
        <div style={{ flex: 1 }} />
        <span className="muted" style={{ fontSize: 12 }}>
          {champion
            ? <>live model: <b>{champion.version}</b> · retrains itself 2× daily</>
            : 'no model yet — observing only, rules in charge'}
        </span>
        <button className="btn btn-primary btn-sm" onClick={trainNow} disabled={busy}>
          {busy ? 'Training…' : '⚡ Train now'}
        </button>
      </div>

      {tab === 'overview' && (
        <div>
          {/* plain-language banner */}
          <div className="card" style={{ padding: '9px 14px', marginBottom: 12, fontSize: 12.5 }}>
            <b>Right now:</b> the model is in <b>shadow mode</b> — it predicts every decision but the
            normal rules still act. It's building a track record you can check below. Nothing is sent
            to employees. <a href="#" onClick={e => { e.preventDefault(); setGuideOpen(true); }}>Full guide →</a>
          </div>

          <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 12 }}>
            <MLStatTile label="Decisions watched (30d)" value={totDecisions}
                        sub="each logged with full context" />
            <MLStatTile label="Learned from history" value={metrics?.backfill_decisions ?? '—'}
                        sub="past days self-graded automatically" />
            <MLStatTile label="Truth collected" value={metrics?.total_labels ?? '—'}
                        sub="outcomes + admin decisions" />
            <MLStatTile label="Agrees with rules" value={fmtPct(avgAgree)}
                        color={avgAgree === null ? undefined : avgAgree > 0.8 ? '#0ca30c' : '#fab219'}
                        sub={avgAgree === null ? 'appears once scoring starts' : 'on the same live decisions'} />
          </div>

          {/* charts row */}
          <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 12 }}>
            <MLDoughnut
              title="What the model is watching"
              caption="Every bot decision in the last 30 days, by type. A bigger slice means more of the bot's daily judgment calls happen there."
              labels={ML_HEADS.map(h => ML_HEAD_LABELS[h])}
              values={ML_HEADS.map(h => (heads[h] || {}).decisions || 0)}
              colors={ML_HEADS.map(h => ML_COLORS[h])}
              centerLabel="decisions"
            />
            <MLDoughnut
              title="Where the truth comes from"
              caption="A model is only as good as its answer key. Most labels arrive automatically (did they actually show up?); the rest come from real admin decisions."
              labels={srcEntries.map(([k]) => ML_SOURCE_LABELS[k] || k)}
              values={srcEntries.map(([, v]) => v)}
              colors={srcEntries.map((_, i) => ML_SOURCE_COLORS[i % ML_SOURCE_COLORS.length])}
              centerLabel="labels"
            />
            <MLQualityBars champion={champion} />
          </div>

          {/* per-head table, compact */}
          <div className="card" style={{ padding: 12, marginBottom: 12 }}>
            <div style={{ fontWeight: 600, marginBottom: 2 }}>Decision types at a glance</div>
            <div className="muted" style={{ fontSize: 11, marginBottom: 8 }}>
              Each row is one question the model is learning to answer.
            </div>
            <table className="t" style={{ fontSize: 12.5, width: '100%' }}>
              <thead><tr>
                <th style={{ textAlign: 'left' }}>Decision</th>
                <th style={{ textAlign: 'left' }}>The question it answers</th>
                <th>Watched</th><th>Truth known</th><th>Agrees with rules</th>
              </tr></thead>
              <tbody>
                {ML_HEADS.map(h => {
                  const m = heads[h] || {};
                  return (
                    <tr key={h}>
                      <td style={{ textAlign: 'left', whiteSpace: 'nowrap' }}>
                        <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: 2, background: ML_COLORS[h], marginRight: 6 }} />
                        <b>{ML_HEAD_LABELS[h]}</b>
                        {h === 'uphold_strike' ? <span className="muted" style={{ fontSize: 10 }}> (never automatic)</span> : null}
                      </td>
                      <td style={{ textAlign: 'left', fontSize: 11.5 }} className="muted">{ML_HEAD_PLAIN[h]}</td>
                      <td>{m.decisions ?? 0}</td>
                      <td>{m.labeled ?? 0}</td>
                      <td style={{ color: m.agreement === null || m.agreement === undefined ? undefined : m.agreement > 0.8 ? '#0ca30c' : '#fab219' }}>
                        {fmtPct(m.agreement)}
                      </td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>

          {/* calibration */}
          <div className="card" style={{ padding: 12 }}>
            <div style={{ display: 'flex', alignItems: 'center', marginBottom: 2 }}>
              <div style={{ fontWeight: 600 }}>Calibration — {ML_HEAD_LABELS[calibHead]}</div>
              <div style={{ flex: 1 }} />
              <select value={calibHead} onChange={e => setCalibHead(e.target.value)}
                      style={{ fontSize: 12, padding: '3px 6px' }}>
                {ML_HEADS.map(h => <option key={h} value={h}>{ML_HEAD_LABELS[h]}</option>)}
              </select>
            </div>
            <div className="muted" style={{ fontSize: 11, marginBottom: 6 }}>
              The honesty check: when the model says "80% likely", does it come true ~80% of the time? The closer the solid
              line hugs the dashed one, the more its confidence can be trusted — that's what would make
              auto-actions safe later.
            </div>
            <MLCalibrationChart head={calibHead} metrics={metrics} />
          </div>
        </div>
      )}

      {tab === 'review' && (
        <div className="card" style={{ padding: 12 }}>
          <div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 10, flexWrap: 'wrap' }}>
            <div style={{ fontWeight: 600 }}>Review queue</div>
            <select value={headFilter} onChange={e => setHeadFilter(e.target.value)} style={{ fontSize: 12, padding: '3px 6px' }}>
              <option value="">All decision types</option>
              {ML_HEADS.map(h => <option key={h} value={h}>{ML_HEAD_LABELS[h]}</option>)}
            </select>
            <label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
              <input type="checkbox" checked={onlyDisagree} onChange={e => setOnlyDisagree(e.target.checked)} />
              disagreements only
            </label>
            <div style={{ flex: 1 }} />
            <button className="btn btn-ghost btn-sm" onClick={loadDecisions}>↻ Refresh</button>
          </div>
          <div className="muted" style={{ fontSize: 11, marginBottom: 8 }}>
            These are the moments the model and the rules pulled in different directions. Your 👍 (model was
            right) or 👎 (model was wrong) becomes training data — reviewing a few each week is the single
            fastest way to make it smarter.
          </div>
          <table className="t" style={{ fontSize: 12, width: '100%' }}>
            <thead><tr>
              <th style={{ textAlign: 'left' }}>When</th><th style={{ textAlign: 'left' }}>Who</th>
              <th style={{ textAlign: 'left' }}>Decision</th><th>Rules said</th><th>Model said</th>
              <th>Truth so far</th><th>Was the model right?</th>
            </tr></thead>
            <tbody>
              {decisions.length === 0 ? (
                <tr><td colSpan={7} className="muted" style={{ padding: 16, textAlign: 'center' }}>
                  {onlyDisagree
                    ? 'No disagreements logged yet — they appear as the model scores live decisions.'
                    : 'No decisions logged yet — they accumulate as the bot works.'}
                </td></tr>
              ) : decisions.map(d => (
                <tr key={d.id} style={d.disagreement ? { background: 'rgba(201,133,0,.07)' } : undefined}>
                  <td style={{ textAlign: 'left', whiteSpace: 'nowrap' }}>{(d.ts || '').slice(0, 16).replace('T', ' ')}</td>
                  <td style={{ textAlign: 'left' }}>{d.display_name}</td>
                  <td style={{ textAlign: 'left', whiteSpace: 'nowrap' }}>
                    <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: 2, background: ML_COLORS[d.head] || '#888', marginRight: 5 }} />
                    {ML_HEAD_LABELS[d.head] || d.head}
                  </td>
                  <td>{d.rule_action ? 'act' : 'no action'}</td>
                  <td style={{ fontWeight: d.disagreement ? 700 : 400 }}>
                    {d.model_p === null || d.model_p === undefined ? '—' : `${Math.round(d.model_p * 100)}% yes`}
                  </td>
                  <td>{(d.labels || []).map((l, i) =>
                    <span key={i} title={ML_SOURCE_LABELS[l.source] || l.source} style={{ marginRight: 4 }}>{l.label ? '✔' : '✘'}</span>)}</td>
                  <td style={{ whiteSpace: 'nowrap' }}>
                    <button className="btn btn-ghost btn-xs" title="Yes — the model's read was right" onClick={() => feedback(d.id, true)}>👍</button>
                    <button className="btn btn-ghost btn-xs" title="No — the model's read was wrong" onClick={() => feedback(d.id, false)}>👎</button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}

      {tab === 'models' && (
        <div className="card" style={{ padding: 12 }}>
          <div style={{ fontWeight: 600, marginBottom: 2 }}>Model registry</div>
          <div className="muted" style={{ fontSize: 11, marginBottom: 8 }}>
            The bot trains a fresh candidate twice a day. While in shadow mode, a clearly better one takes
            over automatically; every version stays on disk, so rolling back is just promoting an older one.
          </div>
          <table className="t" style={{ fontSize: 12, width: '100%' }}>
            <thead><tr>
              <th style={{ textAlign: 'left' }}>Version</th><th>Trained</th><th>Examples</th>
              <th style={{ textAlign: 'left' }}>Quality per decision (test set)</th><th>Status</th><th></th>
            </tr></thead>
            <tbody>
              {models.length === 0 ? (
                <tr><td colSpan={6} className="muted" style={{ padding: 16, textAlign: 'center' }}>
                  No models trained yet — the first one appears after the next scheduled training run.
                </td></tr>
              ) : models.map(m => (
                <tr key={m.version}>
                  <td style={{ textAlign: 'left' }} className="mono">{m.version}</td>
                  <td style={{ whiteSpace: 'nowrap' }}>{(m.trained_at || '').slice(0, 10)}</td>
                  <td>{m.n_train}</td>
                  <td style={{ textAlign: 'left', fontSize: 11 }}>
                    {ML_HEADS.map(h => {
                      const hm = (m.metrics || {})[h];
                      return hm && hm.pr_auc !== undefined
                        ? <span key={h} style={{ marginRight: 8, whiteSpace: 'nowrap' }}>
                            <span style={{ display: 'inline-block', width: 7, height: 7, borderRadius: 2, background: ML_COLORS[h], marginRight: 3 }} />
                            <b>{Math.round(hm.pr_auc * 100)}%</b>
                          </span>
                        : null;
                    })}
                  </td>
                  <td>
                    <span style={{
                      padding: '2px 8px', borderRadius: 10, fontSize: 11,
                      background: m.status === 'champion' ? 'rgba(12,163,12,.15)' : 'rgba(148,163,184,.15)',
                      color: m.status === 'champion' ? '#0ca30c' : undefined,
                    }}>{m.status === 'champion' ? '⭐ live' : m.status}</span>
                  </td>
                  <td>
                    {m.status !== 'champion'
                      ? <button className="btn btn-ghost btn-xs" onClick={() => promote(m.version)}>Promote</button>
                      : null}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </div>
  );
}

window.MLPage = MLPage;
