/* global React */

/* ============================================================
   CloudLayer — Shared Components
   ============================================================ */

const { useState, useEffect, useRef, useMemo, Fragment } = React;

// ----- Tiny SVG sparkline ----------------------------------------------------
function Sparkline({ data, color = 'var(--fg-dim)', width = 80, height = 22, strokeWidth = 1.2, fill = true }) {
  if (!data || !data.length) return null;
  const min = Math.min(...data);
  const max = Math.max(...data);
  const range = max - min || 1;
  const stepX = width / (data.length - 1);
  const pts = data.map((v, i) => [i * stepX, height - ((v - min) / range) * (height - 2) - 1]);
  const d = pts.map((p, i) => (i === 0 ? `M${p[0].toFixed(1)},${p[1].toFixed(1)}` : `L${p[0].toFixed(1)},${p[1].toFixed(1)}`)).join(' ');
  const dFill = `${d} L${width},${height} L0,${height} Z`;
  return (
    <svg className="spark" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" style={{ width, height }}>
      {fill && <path d={dFill} fill={color} className="fill" style={{ opacity: 0.10 }} />}
      <path d={d} fill="none" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

// ----- Model dot + chip ------------------------------------------------------
function MDot({ model, size = 8 }) {
  return <span className="mdot" style={{ background: model.color, width: size, height: size }} />;
}
function MChip({ id, withName = true }) {
  const m = window.OA.modelById(id);
  if (!m) return null;
  return (
    <span className="mchip">
      <MDot model={m} />
      {withName && <span>{m.short}</span>}
    </span>
  );
}

// ----- Prediction row — shows each model's prediction with right/wrong marks
function PredRow({ preds, market, briers, outcome, showRightWrong = false }) {
  const order = ['opus', 'sonnet', 'gpt', 'gemini', 'grok', 'deepseek'];
  return (
    <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
      {order.map(id => {
        const m = window.OA.modelById(id);
        const v = preds[id];
        let mark = null;
        if (showRightWrong && outcome) {
          const said = v > 0.5 ? 'YES' : 'NO';
          const isRight = said === outcome;
          mark = (
            <span className="mono tnum" style={{ fontSize: 10.5, color: isRight ? 'var(--pos)' : 'var(--neg)', marginLeft: 1 }}>
              {isRight ? '✓' : '✗'}
            </span>
          );
        }
        return (
          <div key={id} title={m.name} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
            <span className="mdot" style={{ background: m.color }} />
            <span className="mono tnum" style={{ fontSize: 12, color: 'var(--fg)' }}>
              {(v * 100).toFixed(0)}
            </span>
            {mark}
          </div>
        );
      })}
    </div>
  );
}

// ----- Outcome cell ---------------------------------------------------------
function OutcomeCell({ outcome }) {
  return (
    <span className="mono tnum" style={{
      color: outcome === 'YES' ? 'var(--pos)' : 'var(--neg)',
      fontSize: 12,
      letterSpacing: '0.05em',
    }}>
      {outcome}
    </span>
  );
}

// ----- Page header w/ stats -------------------------------------------------
function PageHead({ crumb, title, titleEm, stats, right }) {
  return (
    <div className="page-head">
      <div className="container">
        {crumb && <div className="crumb">{crumb}</div>}
        <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 32 }}>
          <h1 style={{ flex: 1 }}>
            {title}
            {titleEm && <> <em>{titleEm}</em></>}
          </h1>
          {right}
        </div>
        {stats && (
          <div className="stat-strip">
            {stats.map((s, i) => (
              <div key={i} className="stat">
                <div className="label">{s.label}</div>
                <div className={`value ${s.mono ? 'mono' : ''}`}>{s.value}</div>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

// ----- Tags ------------------------------------------------------------------
function Tag({ children, tone }) {
  return <span className={`tag ${tone || ''}`}>{children}</span>;
}

// ----- Copy button for the prompt -------------------------------------------
function CopyButton({ text, label = 'Copy' }) {
  const [done, setDone] = useState(false);
  const onClick = () => {
    try { navigator.clipboard.writeText(text); } catch (e) {}
    setDone(true);
    setTimeout(() => setDone(false), 1600);
  };
  return (
    <button className={`copy-btn ${done ? 'copied' : ''}`} onClick={onClick}>
      <span style={{ width: 10, height: 10, display: 'inline-block', position: 'relative' }}>
        {done ? (
          <svg viewBox="0 0 10 10" width="10" height="10">
            <path d="M2 5.5 L4.2 7.5 L8 3" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
        ) : (
          <svg viewBox="0 0 10 10" width="10" height="10">
            <rect x="3" y="1" width="6" height="6.5" fill="none" stroke="currentColor" strokeWidth="1" />
            <rect x="1" y="2.5" width="6" height="6.5" fill="var(--bg-elev)" stroke="currentColor" strokeWidth="1" />
          </svg>
        )}
      </span>
      {done ? 'Copied' : label}
    </button>
  );
}

// ----- Prompt code block ----------------------------------------------------
function PromptBlock({ promptText, model, version = 'v3.1', meta }) {
  // Highlight {{vars}} and `1., 2.`
  const tokens = useMemo(() => {
    const parts = [];
    const re = /(\{\{[^}]+\}\})/g;
    let last = 0; let m;
    while ((m = re.exec(promptText))) {
      if (m.index > last) parts.push({ t: 'plain', s: promptText.slice(last, m.index) });
      parts.push({ t: 'var', s: m[0] });
      last = m.index + m[0].length;
    }
    if (last < promptText.length) parts.push({ t: 'plain', s: promptText.slice(last) });
    return parts;
  }, [promptText]);
  return (
    <div className="prompt-block">
      <div className="ph">
        <div className="ph-l">
          <span className="badge">House prompt {version}</span>
          <span>·</span>
          <span>cloudlayer/prompts/binary-forecaster.txt</span>
          {meta && <><span>·</span><span>{meta}</span></>}
        </div>
        <CopyButton text={promptText} />
      </div>
      <pre>
        {tokens.map((tk, i) =>
          tk.t === 'var'
            ? <span key={i} className="var">{tk.s}</span>
            : <Fragment key={i}>{tk.s}</Fragment>
        )}
      </pre>
    </div>
  );
}

// ----- Trajectory chart (line chart) ----------------------------------------
function TrajectoryChart({ lines, height = 380 }) {
  const W = 1100, H = height, padL = 56, padR = 24, padT = 18, padB = 36;
  const innerW = W - padL - padR;
  const innerH = H - padT - padB;

  // All values for y-scale
  const allValues = Object.values(lines).flat();
  const yMin = 0.20, yMax = 0.80; // fixed scale for probability
  const len = lines.opus.length;

  const x = (i) => padL + (i / (len - 1)) * innerW;
  const y = (v) => padT + (1 - (v - yMin) / (yMax - yMin)) * innerH;

  const path = (arr) => arr.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');

  const yTicks = [0.20, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80];
  const dateLabels = [60, 50, 40, 30, 20, 10, 0]; // days ago

  return (
    <svg className="traj-svg" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none">
      {/* Grid + y labels */}
      {yTicks.map(t => (
        <g key={t}>
          <line x1={padL} x2={W - padR} y1={y(t)} y2={y(t)} stroke="var(--border)" strokeWidth="1" />
          <text x={padL - 10} y={y(t) + 3.5} textAnchor="end" fill="var(--fg-faint)" fontFamily="JetBrains Mono, monospace" fontSize="10.5">
            {(t * 100).toFixed(0)}%
          </text>
        </g>
      ))}
      {/* X axis labels */}
      {dateLabels.map((d, idx) => {
        const i = len - 1 - d;
        return (
          <text key={d} x={x(i)} y={H - padB + 18} textAnchor="middle" fill="var(--fg-faint)" fontFamily="JetBrains Mono, monospace" fontSize="10.5">
            {d === 0 ? 'now' : `-${d}d`}
          </text>
        );
      })}

      {/* Market line — dashed */}
      <path d={path(lines.market)} stroke="var(--fg-dim)" strokeWidth="1.2" strokeDasharray="4 4" fill="none" />

      {/* Model lines */}
      {window.OA.MODELS.map(m => (
        <path key={m.id} d={path(lines[m.id])} stroke={m.rawColor} strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" />
      ))}

      {/* End dots */}
      {window.OA.MODELS.map(m => {
        const v = lines[m.id][len - 1];
        return (
          <g key={m.id + 'dot'}>
            <circle cx={x(len - 1)} cy={y(v)} r="3.5" fill={m.rawColor} />
            <text x={x(len - 1) + 8} y={y(v) + 3.5} fill={m.rawColor} fontFamily="JetBrains Mono, monospace" fontSize="11">
              {(v * 100).toFixed(0)}
            </text>
          </g>
        );
      })}

      {/* Market end dot */}
      <circle cx={x(len - 1)} cy={y(lines.market[len - 1])} r="3" fill="var(--fg-dim)" />
    </svg>
  );
}

// ----- MarketTrajectory ----------------------------------------------------
// Used by the market detail page. Plots the priceHistory ticks on a fixed
// 0–100% axis so a 12% market doesn't look like a step function.
function MarketTrajectory({ history = [], height = 300 }) {
  const W = 1100;
  const padL = 56, padR = 24, padT = 24, padB = 28;
  const innerW = W - padL - padR;
  const innerH = height - padT - padB;

  if (!history || history.length < 2) {
    return (
      <div className="dim" style={{ padding: 28, textAlign: 'center' }}>
        Not enough price history yet. Check back in a few minutes.
      </div>
    );
  }

  // Time span: first tick to now.
  const startMs = new Date(history[0].t).getTime();
  const endMs   = new Date(history[history.length - 1].t).getTime();
  const span = Math.max(endMs - startMs, 1);

  const xOf = (ms) => padL + ((ms - startMs) / span) * innerW;
  const yOf = (v)  => padT + (1 - Math.max(0, Math.min(1, v))) * innerH;

  const d = history.map((p, i) => {
    const ms = new Date(p.t).getTime();
    return `${i === 0 ? 'M' : 'L'}${xOf(ms).toFixed(1)},${yOf(p.yes).toFixed(1)}`;
  }).join(' ');

  const yTicks = [0, 0.25, 0.5, 0.75, 1];

  const last = history[history.length - 1];
  const lastPct = Math.round((last.yes || 0) * 100);

  // X labels — first, midpoint, last
  const fmt = (ms) => {
    const d2 = new Date(ms);
    const day = d2.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
    const time = d2.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
    return `${day} ${time}`;
  };

  return (
    <svg className="traj-svg" viewBox={`0 0 ${W} ${height}`} preserveAspectRatio="none" style={{ width: '100%', height: 'auto' }}>
      {/* Y grid + labels */}
      {yTicks.map((t) => (
        <g key={t}>
          <line x1={padL} x2={W - padR} y1={yOf(t)} y2={yOf(t)}
                stroke="var(--border)" strokeWidth="1"
                strokeDasharray={t === 0.5 ? '0' : '3 5'} opacity={t === 0.5 ? 1 : 0.5} />
          <text x={padL - 10} y={yOf(t) + 3.5} textAnchor="end"
                fill="var(--fg-faint)" fontFamily="JetBrains Mono, monospace" fontSize="10.5">
            {Math.round(t * 100)}%
          </text>
        </g>
      ))}
      {/* Y axis hint */}
      <text x={padL - 48} y={padT - 6} fill="var(--fg-faint)"
            fontFamily="JetBrains Mono, monospace" fontSize="9.5" letterSpacing="0.08em">
        YES %
      </text>

      {/* X labels — start, mid, now */}
      <text x={padL} y={height - padB + 18} textAnchor="start"
            fill="var(--fg-faint)" fontFamily="JetBrains Mono, monospace" fontSize="10.5">
        {fmt(startMs)}
      </text>
      <text x={(padL + W - padR) / 2} y={height - padB + 18} textAnchor="middle"
            fill="var(--fg-faint)" fontFamily="JetBrains Mono, monospace" fontSize="10.5">
        {fmt((startMs + endMs) / 2)}
      </text>
      <text x={W - padR} y={height - padB + 18} textAnchor="end"
            fill="var(--fg-faint)" fontFamily="JetBrains Mono, monospace" fontSize="10.5">
        now
      </text>

      {/* Fill */}
      <path d={`${d} L${xOf(endMs).toFixed(1)},${yOf(0).toFixed(1)} L${padL},${yOf(0).toFixed(1)} Z`}
            fill="var(--fg)" opacity="0.05" />
      {/* Line */}
      <path d={d} stroke="var(--fg)" strokeWidth="1.6" fill="none"
            strokeLinecap="round" strokeLinejoin="round" />

      {/* Endpoint dot + value */}
      <circle cx={xOf(endMs)} cy={yOf(last.yes)} r="4" fill="var(--accent)" />
      <text x={xOf(endMs) - 8} y={yOf(last.yes) - 8} textAnchor="end"
            fill="var(--accent)" fontFamily="JetBrains Mono, monospace" fontSize="12">
        {lastPct}%
      </text>
    </svg>
  );
}

// ----- Live pill ------------------------------------------------------------
function LivePill({ children }) {
  return (
    <span className="live-pill">
      <span className="blink" />
      {children}
    </span>
  );
}

// ----- Filter row -----------------------------------------------------------
function FilterChips({ options, value, onChange }) {
  return (
    <div className="filter-row">
      {options.map(o => (
        <button key={o.value} className={`fchip ${value === o.value ? 'active' : ''}`} onClick={() => onChange(o.value)}>
          {o.label}
        </button>
      ))}
    </div>
  );
}

// ----- Aggregate accuracy chart (multi-line, accuracy % over time) ---------
function AggregateChart({ data, height = 280, mode = 'accuracy' }) {
  const W = 1100, H = height, padL = 60, padR = 90, padT = 22, padB = 32;
  const innerW = W - padL - padR;
  const innerH = H - padT - padB;

  const all = data.flatMap(d => d.trend);
  let yMin = Math.min(...all);
  let yMax = Math.max(...all);
  const range = yMax - yMin || 0.05;
  yMin = Math.max(0, yMin - range * 0.15);
  yMax = Math.min(1, yMax + range * 0.15);

  const len = data[0].trend.length;
  const x = (i) => padL + (i / (len - 1)) * innerW;
  // For accuracy mode: higher value = higher on chart (top = better)
  // For brier mode: lower value = higher on chart (top = better) — flip
  const y = (v) => {
    const norm = (v - yMin) / (yMax - yMin);
    return mode === 'accuracy'
      ? padT + (1 - norm) * innerH
      : padT + norm * innerH;
  };

  const path = (arr) => arr.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');

  const yTickStep = (yMax - yMin) / 4;
  const yTicks = [0, 1, 2, 3, 4].map(i => yMin + yTickStep * i);

  const dayLabels = [30, 20, 10, 0];

  const fmt = (v) => mode === 'accuracy' ? `${Math.round(v * 100)}%` : v.toFixed(3);

  return (
    <svg className="agg-svg" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none">
      {/* Y grid + labels */}
      {yTicks.map((t, i) => (
        <g key={i}>
          <line x1={padL} x2={W - padR} y1={y(t)} y2={y(t)} stroke="var(--border)" strokeWidth="1" />
          <text x={padL - 10} y={y(t) + 3.5} textAnchor="end" fill="var(--fg-faint)" fontFamily="JetBrains Mono, monospace" fontSize="10.5">
            {fmt(t)}
          </text>
        </g>
      ))}
      {/* X axis labels */}
      {dayLabels.map(d => {
        const i = len - 1 - d;
        return (
          <text key={d} x={x(i)} y={H - padB + 18} textAnchor="middle" fill="var(--fg-faint)" fontFamily="JetBrains Mono, monospace" fontSize="10.5">
            {d === 0 ? 'today' : `-${d}d`}
          </text>
        );
      })}
      {/* Better / Worse axis label */}
      <text x={padL - 48} y={padT + 8} fill="var(--fg-faint)" fontFamily="JetBrains Mono, monospace" fontSize="9.5" letterSpacing="0.08em">↑ BETTER</text>
      <text x={padL - 48} y={H - padB - 4} fill="var(--fg-faint)" fontFamily="JetBrains Mono, monospace" fontSize="9.5" letterSpacing="0.08em">↓ WORSE</text>

      {/* Lines */}
      {data.map(d => {
        const m = window.OA.modelById(d.model);
        return <path key={d.model} d={path(d.trend)} stroke={m.rawColor} strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" />;
      })}

      {/* End labels */}
      {data.map((d, idx) => {
        const m = window.OA.modelById(d.model);
        const v = d.trend[d.trend.length - 1];
        return (
          <g key={d.model + 'end'}>
            <circle cx={x(len - 1)} cy={y(v)} r="3.5" fill={m.rawColor} />
            <text x={x(len - 1) + 10} y={y(v) + 3.5} fill={m.rawColor} fontFamily="JetBrains Mono, monospace" fontSize="11">
              {m.short.length > 6 ? m.short.slice(0, 6) : m.short} {fmt(v)}
            </text>
          </g>
        );
      })}
    </svg>
  );
}

// ----- Category tabs (horizontal, scrollable) ------------------------------
function CategoryTabs({ value, onChange, categories }) {
  const cats = [{ slug: 'all', name: 'All', markets: null }, ...categories];
  return (
    <div className="cat-tabs">
      {cats.map(c => (
        <button
          key={c.slug}
          className={`cat-tab ${value === c.slug ? 'active' : ''}`}
          onClick={() => onChange(c.slug)}
        >
          <span>{c.name}</span>
          {c.markets != null && (
            <span className="cat-tab-count">{c.markets}</span>
          )}
        </button>
      ))}
    </div>
  );
}

// ----- MarketCard (analytical card — model predictions, not trading) ------
function MarketCard({ market, navigate }) {
  const m = market;
  const yes = Math.round(m.price * 100);
  const { MODELS, CATEGORIES, modelById } = window.OA;

  // Each model's deviation from market price (in percentage points)
  const ranked = MODELS.map(mm => {
    const p = m.preds[mm.id];
    return { mm, p, deltaPct: Math.round((p - m.price) * 100) };
  }).sort((a, b) => b.p - a.p);

  // Consensus = mean of model preds
  const consensus = MODELS.reduce((s, mm) => s + m.preds[mm.id], 0) / MODELS.length;
  const spreadPct = Math.round((Math.max(...MODELS.map(mm => m.preds[mm.id]))
                             - Math.min(...MODELS.map(mm => m.preds[mm.id]))) * 100);

  // Category accuracy context — the leader model's accuracy on this category
  const cat = CATEGORIES.find(c => c.name === m.cat);
  const leaderModel = cat ? modelById(cat.leader) : null;
  const catAccuracy = cat ? Math.round(cat.accuracy * 100) : null;

  return (
    <div className="mcard" onClick={() => navigate && navigate('market')}>
      <div className="mcard-top">
        <div className="mcard-meta">
          <span className="cat-pill">{m.cat}</span>
          <span className="mcard-sub">{m.subcat}</span>
        </div>
        <div className="mcard-end mono">{m.vol}</div>
      </div>

      <div className="mcard-q">{m.q}</div>

      {/* Chart band with market price line */}
      <div className="mcard-chart">
        <Sparkline data={m.spark} color="var(--fg)" width={300} height={56} fill={true} strokeWidth={1.4} />
        <div className="mcard-chart-end-label mono">market {yes}%</div>
      </div>

      {/* Three figures */}
      <div className="mcard-figures">
        <div>
          <div className="mcard-fig-val mono tnum">{yes}<span style={{ fontSize: 13, color: 'var(--fg-faint)' }}>%</span></div>
          <div className="mcard-fig-lbl">Market</div>
        </div>
        <div>
          <div className="mcard-fig-val mono tnum">{Math.round(consensus * 100)}<span style={{ fontSize: 13, color: 'var(--fg-faint)' }}>%</span></div>
          <div className="mcard-fig-lbl">6-model consensus</div>
        </div>
        <div>
          <div className="mcard-fig-val mono tnum">±{spreadPct}<span style={{ fontSize: 13, color: 'var(--fg-faint)' }}>pp</span></div>
          <div className="mcard-fig-lbl">Model spread</div>
        </div>
      </div>

      {/* Per-model lines */}
      <div className="mcard-models-grid">
        {ranked.map(({ mm, p, deltaPct }) => (
          <div key={mm.id} className="mcard-mrow">
            <span className="mdot" style={{ background: mm.rawColor }} />
            <span className="mcard-mname">{mm.short}</span>
            <span className="mono tnum mcard-mp">{Math.round(p * 100)}</span>
            <span className={`mono tnum mcard-md ${deltaPct > 0 ? 'pos' : deltaPct < 0 ? 'neg' : 'faint'}`}>
              {deltaPct > 0 ? '+' : ''}{deltaPct}
            </span>
          </div>
        ))}
      </div>

      {/* Category accuracy context strip */}
      {leaderModel && (
        <div className="mcard-context">
          <div className="mcard-context-row">
            <span className="uppercase faint">{m.cat} accuracy</span>
            <span className="mono tnum mcard-context-pct">{catAccuracy}<span style={{ fontSize: 11, color: 'var(--fg-faint)' }}>%</span></span>
          </div>
          <div className="mcard-context-row">
            <span className="mono dim" style={{ fontSize: 11.5, display: 'inline-flex', alignItems: 'center', gap: 6 }}>
              <MDot model={leaderModel} />
              {leaderModel.short} leads · {cat.markets} resolved
            </span>
            <span className="mono faint" style={{ fontSize: 11 }}>Resolves {m.end} · {m.endDays}d</span>
          </div>
        </div>
      )}
    </div>
  );
}

// ----- MarketGrid ----------------------------------------------------------
function MarketGrid({ markets, navigate }) {
  return (
    <div className="mgrid">
      {markets.map(m => <MarketCard key={m.id} market={m} navigate={navigate} />)}
    </div>
  );
}

// ===========================================================================
// LIVE-DATA HOOKS + COMPONENTS — used by the rewritten pages.
// ===========================================================================

// useMeta — reads window.OA + listens for 'cloudlayer:meta' updates.
function useMeta() {
  const [tick, setTick] = useState(0);
  useEffect(() => {
    const h = () => setTick((t) => t + 1);
    window.addEventListener('cloudlayer:meta', h);
    window.addEventListener('cloudlayer:auth', h);
    return () => {
      window.removeEventListener('cloudlayer:meta', h);
      window.removeEventListener('cloudlayer:auth', h);
    };
  }, []);
  return useMemo(
    () => ({
      models: window.OA.MODELS,
      categories: window.OA.CATEGORIES,
      modelById: (id) => window.OA.modelById(id),
      categoryBySlug: (s) => window.OA.categoryBySlug(s),
    }),
    [tick],
  );
}

// useAuth — exposes the current signed-in user reactively.
function useAuth() {
  const [user, setUser] = useState(() => window.CloudLayerAuth?.getUser() || null);
  const [meta, setMetaState] = useState(() => window.CloudLayerAuth?.getMeta() || null);
  const [tier, setTier] = useState(() => window.CloudLayerAuth?.getTier() || null);
  useEffect(() => {
    const h = (e) => {
      setUser(e.detail.user || null);
      if (e.detail.meta) setMetaState(e.detail.meta);
      if (e.detail.tier) setTier(e.detail.tier);
    };
    window.addEventListener('cloudlayer:auth', h);
    return () => window.removeEventListener('cloudlayer:auth', h);
  }, []);
  return { user, meta, tier, signedIn: !!user };
}

// useApi(fn, deps) — wraps an async API call with loading/data/error state.
// Re-runs whenever any dep changes. Returns { data, error, loading, refresh }.
function useApi(fn, deps) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);
  const fnRef = useRef(fn);
  fnRef.current = fn;
  const tickRef = useRef(0);

  const refresh = React.useCallback(() => {
    const myTick = ++tickRef.current;
    setLoading(true);
    setError(null);
    Promise.resolve()
      .then(() => fnRef.current())
      .then((d) => { if (tickRef.current === myTick) { setData(d); setLoading(false); } })
      .catch((e) => { if (tickRef.current === myTick) { setError(e); setLoading(false); } });
  }, []);

  useEffect(() => { refresh(); /* eslint-disable-next-line */ }, deps);
  return { data, error, loading, refresh };
}

// Skeleton states + error inline.
function Loading({ height = 80, label }) {
  return (
    <div className="panel" style={{ padding: 18, minHeight: height, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <span className="mono faint" style={{ fontSize: 12 }}>{label || 'Loading…'}</span>
    </div>
  );
}

// LoadingBar — a thin animated strip that sits above a section being
// refetched. Used when items are already on screen and we don't want to
// blank the page out (filter change, infinite-list reset).
function LoadingBar() {
  return (
    <div style={{
      position: 'relative',
      height: 2,
      width: '100%',
      overflow: 'hidden',
      background: 'rgba(212, 174, 90, 0.10)',
      marginBottom: 16,
      borderRadius: 1,
    }}>
      <div style={{
        position: 'absolute',
        top: 0, bottom: 0, left: 0,
        width: '36%',
        background: 'var(--accent)',
        borderRadius: 1,
        animation: 'oa-loading-slide 1.1s ease-in-out infinite',
      }} />
      <style>{`
        @keyframes oa-loading-slide {
          0%   { transform: translateX(-110%); }
          100% { transform: translateX(310%); }
        }
      `}</style>
    </div>
  );
}
function ApiError({ error, hint }) {
  if (!error) return null;
  return (
    <div className="panel" style={{ padding: 18, borderColor: 'var(--neg)' }}>
      <div className="mono neg">{error.message || String(error)}</div>
      {hint && <div className="dim" style={{ marginTop: 6, fontSize: 13 }}>{hint}</div>}
    </div>
  );
}

// ===========================================================================
// LiveMarketCard — accepts the /api/markets shape (predictions: [{modelId,…}]).
// Renders the same visual treatment as the static MarketCard.
// ===========================================================================
function LiveMarketCard({ market, navigate }) {
  const { models } = useMeta();
  const yesPct = Math.round((market.yesPrice ?? 0.5) * 100);
  const preds = market.predictions || [];
  const predByModel = useMemo(() => Object.fromEntries(preds.map((p) => [p.modelId, p])), [preds]);

  // Resolved state — render a different layout that emphasises the outcome
  // and shows which model actually called it right.
  const isResolved = !!(market.resolved && market.outcome);

  const consensus = market.consensus?.yesProbability ?? null;
  const consensusPct = consensus == null ? null : Math.round(consensus * 100);
  const consensusVerdict = consensus == null ? null : consensus >= 0.5 ? 'YES' : 'NO';

  const history = (market.priceHistory || []).slice(-30).map((p) => p.yes);
  const sparkData = history.length > 1 ? history : [market.yesPrice ?? 0.5, market.yesPrice ?? 0.5];

  const probs = preds.map((p) => p.yesProbability).filter((v) => Number.isFinite(v));
  const spread = probs.length ? Math.round((Math.max(...probs) - Math.min(...probs)) * 100) : 0;

  // Best-model on a resolved market = the prediction that called the right
  // side AND was most confident (furthest from 50% in the correct direction).
  let bestModel = null;
  let calledRight = 0;
  if (isResolved) {
    for (const p of preds) {
      const right = p.verdict === market.outcome;
      if (right) calledRight++;
      if (right) {
        const confidence = Math.abs(p.yesProbability - 0.5);
        if (!bestModel || confidence > bestModel.confidence) {
          bestModel = { ...p, confidence };
        }
      }
    }
  }

  const days = market.endDate
    ? Math.max(0, Math.round((new Date(market.endDate).getTime() - Date.now()) / 86_400_000))
    : null;

  return (
    <div className="mcard" onClick={() => navigate && navigate('market', { id: market.polymarketId })} style={{ position: 'relative' }}>
      <div className="mcard-top">
        <div className="mcard-meta">
          <span className="cat-pill">{market.category || 'misc'}</span>
          {isResolved && (
            <span className="cat-pill" style={{
              background: market.outcome === 'YES' ? 'rgba(111,181,131,0.18)' : 'rgba(217,106,106,0.18)',
              color: market.outcome === 'YES' ? 'var(--pos)' : 'var(--neg)',
              fontWeight: 600,
            }}>resolved · {market.outcome}</span>
          )}
        </div>
        <div className="row gap-8" style={{ alignItems: 'center' }}>
          <span className="mcard-end mono">
            {isResolved ? 'settled' : (days == null ? '' : days === 0 ? 'today' : `${days}d`)}
          </span>
          <button
            type="button"
            onClick={(e) => {
              e.stopPropagation();
              navigate && navigate('share', { id: market.polymarketId });
            }}
            title="Share this market"
            aria-label="Share"
            className="btn-ghost"
            style={{
              padding: '4px 7px',
              fontSize: 11,
              fontFamily: 'var(--ff-mono)',
              color: 'var(--fg-faint)',
              letterSpacing: '0.06em',
              borderRadius: 3,
            }}
          >
            ↗
          </button>
        </div>
      </div>

      <div className="mcard-q" title={market.question}>{market.question}</div>

      <div className="mcard-chart">
        <Sparkline data={sparkData} color="var(--fg)" width={300} height={56} fill strokeWidth={1.4} />
        <div className="mcard-chart-end-label mono">{isResolved ? `final ${yesPct}%` : `market ${yesPct}%`}</div>
      </div>

      {isResolved ? (
        // ─── Resolved layout ───────────────────────────────────────────────
        <div className="mcard-figures">
          <div>
            <div className="mcard-fig-val mono tnum"
                 style={{ color: market.outcome === 'YES' ? 'var(--pos)' : 'var(--neg)' }}>
              {market.outcome}
            </div>
            <div className="mcard-fig-lbl">Outcome</div>
          </div>
          <div>
            <div className="mcard-fig-val mono tnum"
                 style={{ fontSize: 16, color: bestModel ? 'var(--accent)' : 'var(--fg-faint)' }}>
              {bestModel ? (bestModel.modelLabel || bestModel.modelId) : '— none'}
            </div>
            <div className="mcard-fig-lbl">Best call</div>
          </div>
          <div>
            <div className="mcard-fig-val mono tnum"
                 style={{ color: calledRight > 0 ? 'var(--pos)' : 'var(--neg)' }}>
              {calledRight}<span style={{ fontSize: 13, color: 'var(--fg-faint)' }}>/{preds.length}</span>
            </div>
            <div className="mcard-fig-lbl">Got it right</div>
          </div>
        </div>
      ) : (
        // ─── Open layout (the original three stats) ───────────────────────
        <div className="mcard-figures">
          <div>
            <div className="mcard-fig-val mono tnum">
              {yesPct}<span style={{ fontSize: 13, color: 'var(--fg-faint)' }}>%</span>
            </div>
            <div className="mcard-fig-lbl">Market</div>
          </div>
          <div>
            <div
              className="mcard-fig-val mono tnum"
              style={{ color: consensusVerdict === 'YES' ? 'var(--pos)' : consensusVerdict === 'NO' ? 'var(--neg)' : 'var(--fg)' }}
            >
              {consensusPct == null ? '—' : consensusPct}
              {consensusPct != null && <span style={{ fontSize: 13, color: 'var(--fg-faint)' }}>%</span>}
            </div>
            <div className="mcard-fig-lbl">CloudLayer</div>
          </div>
          <div>
            <div className="mcard-fig-val mono tnum">±{spread}<span style={{ fontSize: 13, color: 'var(--fg-faint)' }}>pp</span></div>
            <div className="mcard-fig-lbl">Spread</div>
          </div>
        </div>
      )}

      <div className="mcard-models-grid">
        {models.map((m) => {
          const p = predByModel[m.id];
          const pct = p ? Math.round(p.yesProbability * 100) : null;
          // Resolved cards show ✓/✗ instead of delta — much more informative
          // ("you were 4pp off the line" doesn't tell you if you were right).
          let rightCol;
          if (isResolved) {
            if (p) {
              const right = p.verdict === market.outcome;
              rightCol = (
                <span className={`mono tnum mcard-md ${right ? 'pos' : 'neg'}`} style={{ fontSize: 13 }}>
                  {right ? '✓' : '✗'}
                </span>
              );
            } else {
              rightCol = <span className="mono tnum mcard-md faint">—</span>;
            }
          } else {
            const deltaPct = p ? Math.round((p.yesProbability - (market.yesPrice ?? 0.5)) * 100) : null;
            rightCol = (
              <span
                className={`mono tnum mcard-md ${
                  deltaPct == null ? 'faint' : deltaPct > 0 ? 'pos' : deltaPct < 0 ? 'neg' : 'faint'
                }`}
              >
                {deltaPct == null ? '' : (deltaPct > 0 ? '+' : '') + deltaPct}
              </span>
            );
          }
          return (
            <div key={m.id} className="mcard-mrow" title={p?.reasoning || (m.enabled ? 'awaiting prediction' : 'no API key')}>
              <span className="mdot" style={{ background: m.color, opacity: p ? 1 : 0.35 }} />
              <span className="mcard-mname" style={{ opacity: p ? 1 : 0.5 }}>{m.short}</span>
              <span className="mono tnum mcard-mp">{pct == null ? '—' : pct}</span>
              {rightCol}
            </div>
          );
        })}
      </div>

      <div className="mcard-foot">
        <span className="mono faint" style={{ fontSize: 11 }}>
          ${Math.round((market.volume || 0) / 1000) >= 1000
            ? '$' + ((market.volume || 0) / 1e6).toFixed(1) + 'M'
            : '$' + Math.round((market.volume || 0) / 1000) + 'K'}
        </span>
        <span className="mono faint" style={{ fontSize: 11 }}>·</span>
        <span className="mono faint" style={{ fontSize: 11 }}>{preds.length} of {models.length} models</span>
      </div>
    </div>
  );
}

// LiveMarketGrid — pass through.
function LiveMarketGrid({ markets, navigate }) {
  if (!markets || !markets.length) {
    return (
      <div className="panel" style={{ padding: 28, textAlign: 'center' }}>
        <div className="dim">No markets to show yet.</div>
        <div className="mono faint" style={{ fontSize: 12, marginTop: 6 }}>The poller is still warming up. First tick takes ~30s.</div>
      </div>
    );
  }
  return (
    <div className="mgrid">
      {markets.map((m) => <LiveMarketCard key={m.polymarketId} market={m} navigate={navigate} />)}
    </div>
  );
}

// ===========================================================================
// UserMenu + SignInButton — nav-right widgets.
// ===========================================================================
function SignInButton({ compact = false }) {
  const containerRef = useRef(null);
  const [renderedManual, setRenderedManual] = useState(false);
  const { meta } = useAuth();
  const enabled = meta?.auth?.googleEnabled;

  useEffect(() => {
    if (!enabled || !containerRef.current) return;
    let cancelled = false;
    const tryRender = () => {
      if (cancelled) return;
      const ok = window.CloudLayerAuth?.renderButton?.(containerRef.current);
      if (!ok) setTimeout(tryRender, 200);
      else setRenderedManual(true);
    };
    tryRender();
    return () => { cancelled = true; };
  }, [enabled]);

  if (!enabled) {
    return (
      <button
        className="btn"
        onClick={() => alert('Google sign-in not configured. Set GOOGLE_OAUTH_CLIENT_ID in .env to enable.')}
        title="GOOGLE_OAUTH_CLIENT_ID not set"
      >
        Sign in
      </button>
    );
  }
  return (
    <div style={{ display: 'inline-flex', alignItems: 'center', minWidth: compact ? 110 : 180 }}>
      <div ref={containerRef} />
      {!renderedManual && <span className="mono faint" style={{ fontSize: 11 }}>Loading sign-in…</span>}
    </div>
  );
}

function UserMenu({ navigate }) {
  const { user } = useAuth();
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    if (!open) return;
    const onClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', onClick);
    return () => document.removeEventListener('mousedown', onClick);
  }, [open]);

  if (!user) return null;
  const initials = (user.name || user.email || '?').slice(0, 2).toUpperCase();
  return (
    <div ref={ref} style={{ position: 'relative' }}>
      <button
        className="btn-ghost"
        onClick={() => setOpen((o) => !o)}
        style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '4px 8px' }}
      >
        {user.picture
          ? <img src={user.picture} alt="" width="24" height="24" style={{ borderRadius: '50%' }} />
          : <span style={{ width: 24, height: 24, borderRadius: '50%', background: 'var(--bg-elev-2)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'var(--ff-mono)', fontSize: 10, color: 'var(--accent)' }}>{initials}</span>}
        <span className="mono" style={{ fontSize: 12 }}>{user.handle || user.email.split('@')[0]}</span>
      </button>
      {open && (
        <div
          className="panel"
          style={{
            position: 'absolute', right: 0, top: 'calc(100% + 8px)', minWidth: 220,
            background: 'var(--bg-elev)', border: '1px solid var(--border)',
            borderRadius: 4, zIndex: 50,
          }}
        >
          <div style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)' }}>
            <div style={{ fontSize: 13, color: 'var(--fg)' }}>{user.name || user.handle}</div>
            <div className="mono faint" style={{ fontSize: 11, marginTop: 2 }}>{user.email}</div>
          </div>
          <button
            className="nav-link"
            style={{ display: 'block', width: '100%', textAlign: 'left', padding: '10px 14px', borderRadius: 0 }}
            onClick={() => { setOpen(false); navigate('profile'); }}
          >Your prompts</button>
          <button
            className="nav-link"
            style={{ display: 'block', width: '100%', textAlign: 'left', padding: '10px 14px', borderRadius: 0, color: 'var(--neg)' }}
            onClick={() => { setOpen(false); window.CloudLayerAuth?.signOut?.(); }}
          >Sign out</button>
        </div>
      )}
    </div>
  );
}

// ===========================================================================
// CategoryDropdown — nav dropdown listing all backend categories with live
// counts from /api/categories. Re-fetches lazily on open so the counts are
// fresh without spamming the API on every page render.
// ===========================================================================
function CategoryDropdown({ navigate, currentCategory }) {
  const { categories: metaCats } = useMeta();
  const [open, setOpen] = useState(false);
  const [live, setLive] = useState(null);
  const ref = useRef(null);

  useEffect(() => {
    if (!open) return;
    // Fetch live counts whenever the dropdown opens — cheap, single call.
    window.CloudLayerAPI.listCategories()
      .then((r) => setLive(r.items || []))
      .catch(() => {});
    const onClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', onClick);
    return () => document.removeEventListener('mousedown', onClick);
  }, [open]);

  // Merge: meta provides the canonical list (incl. blurb); live overlays counts.
  const liveBySlug = Object.fromEntries((live || []).map((c) => [c.slug, c]));
  const merged = (metaCats || []).map((c) => ({ ...c, ...(liveBySlug[c.slug] || {}) }));

  return (
    <div ref={ref} style={{ position: 'relative' }}>
      <button
        className={`nav-link ${currentCategory ? 'active' : ''}`}
        onClick={() => setOpen((o) => !o)}
        style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
      >
        Markets <span style={{ fontSize: 9, color: 'var(--fg-faint)' }}>▾</span>
      </button>
      {open && (
        <div
          className="panel"
          style={{
            position: 'absolute', left: 0, top: 'calc(100% + 6px)', minWidth: 300,
            background: 'var(--bg-elev)', border: '1px solid var(--border)',
            borderRadius: 4, zIndex: 50, padding: '6px 0',
          }}
        >
          <button
            className={`nav-link ${!currentCategory ? 'active' : ''}`}
            style={{ display: 'block', width: '100%', textAlign: 'left', padding: '10px 14px', borderRadius: 0 }}
            onClick={() => { setOpen(false); navigate('home'); }}
          >All markets</button>
          {merged.length === 0 && (
            <div className="mono faint" style={{ padding: '10px 14px', fontSize: 11 }}>Loading…</div>
          )}
          {merged.map((c) => {
            const hasCounts = Number.isFinite(c.markets);
            return (
              <button
                key={c.slug}
                className={`nav-link ${currentCategory === c.slug ? 'active' : ''}`}
                style={{ display: 'flex', justifyContent: 'space-between', width: '100%', textAlign: 'left', padding: '10px 14px', borderRadius: 0, gap: 12 }}
                onClick={() => { setOpen(false); navigate('category', { slug: c.slug }); }}
              >
                <span>{c.name}</span>
                <span className="mono faint" style={{ fontSize: 11 }}>
                  {hasCounts ? `${c.markets} · ${formatMoney(c.volume || 0)}` : '…'}
                </span>
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
}

// ===========================================================================
// useInfiniteList(loadPage, deps, pageSize)
//   loadPage(offset, limit) → Promise<{ items: [], hasMore: boolean }>
//   deps   — when these change, the list resets (use to wire category filter)
// Returns { items, loading, loadingMore, error, hasMore, sentinelRef, refresh }
// sentinelRef is for an IntersectionObserver target rendered at the bottom of
// the list. When that element scrolls into view, the next page is fetched and
// appended in place.
// ===========================================================================
function useInfiniteList(loadPage, deps, pageSize = 24) {
  const [items, setItems] = useState([]);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);
  const [loadingMore, setLoadingMore] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  // `total` is the full server-side match count for the current filter.
  // Used by the UI to say "24 of 137 markets" rather than just "24".
  const [total, setTotal] = useState(null);
  // Callback ref — when the sentinel mounts or unmounts, we get the node
  // here and (re)attach the IntersectionObserver in the effect below. This
  // is the only correct way to react to "an element appeared in the DOM";
  // a plain useRef won't trigger any effect.
  const [sentinelEl, setSentinelEl] = useState(null);
  const sentinelRef = React.useCallback((node) => setSentinelEl(node), []);
  const offsetRef = useRef(0);
  const epochRef = useRef(0);
  const inflightRef = useRef(false);
  const fnRef = useRef(loadPage);
  fnRef.current = loadPage;

  // Reset whenever deps change. We DON'T clear items here — that would make
  // the grid blink to empty between every filter click. Instead we keep the
  // current items rendered and flip `loading` true so the UI can show a soft
  // overlay. Items get replaced atomically once the new page arrives.
  useEffect(() => {
    const myEpoch = ++epochRef.current;
    offsetRef.current = 0;
    inflightRef.current = true;
    setError(null);
    setLoading(true);
    setHasMore(true);
    Promise.resolve()
      .then(() => fnRef.current(0, pageSize))
      .then((r) => {
        if (epochRef.current !== myEpoch) return;
        setItems(r.items || []);
        setHasMore(!!r.hasMore);
        setTotal(typeof r.total === 'number' ? r.total : null);
        offsetRef.current = (r.items || []).length;
        setLoading(false);
        inflightRef.current = false;
      })
      .catch((e) => {
        if (epochRef.current !== myEpoch) return;
        setError(e);
        setItems([]);          // wipe on error so the empty state shows
        setTotal(0);
        setLoading(false);
        inflightRef.current = false;
      });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  const loadMore = React.useCallback(() => {
    if (inflightRef.current || !hasMore) return;
    const myEpoch = epochRef.current;
    inflightRef.current = true;
    setLoadingMore(true);
    fnRef.current(offsetRef.current, pageSize)
      .then((r) => {
        if (epochRef.current !== myEpoch) return;
        setItems((prev) => {
          // De-dupe by polymarketId in case the same row gets paginated twice
          // (rank shuffles between requests).
          const seen = new Set(prev.map((p) => p.polymarketId));
          const next = (r.items || []).filter((p) => !seen.has(p.polymarketId));
          return prev.concat(next);
        });
        setHasMore(!!r.hasMore);
        if (typeof r.total === 'number') setTotal(r.total);
        offsetRef.current += (r.items || []).length;
        setLoadingMore(false);
        inflightRef.current = false;
      })
      .catch((e) => {
        if (epochRef.current !== myEpoch) return;
        setError(e);
        setLoadingMore(false);
        inflightRef.current = false;
      });
  }, [hasMore, pageSize]);

  // Observe the sentinel. The effect re-runs when EITHER the DOM node
  // changes (sentinel mounted/unmounted via callback ref) OR loadMore changes
  // (its identity depends on hasMore). rootMargin pre-fetches before the
  // user actually hits the bottom so the new cards arrive before they see a gap.
  useEffect(() => {
    if (!sentinelEl) return;
    if (!('IntersectionObserver' in window)) {
      // Fallback: poll scroll position.
      const onScroll = () => {
        const rect = sentinelEl.getBoundingClientRect();
        if (rect.top - window.innerHeight < 600) loadMore();
      };
      window.addEventListener('scroll', onScroll, { passive: true });
      // Also fire once in case the sentinel is already near the viewport.
      onScroll();
      return () => window.removeEventListener('scroll', onScroll);
    }
    const io = new IntersectionObserver(
      (entries) => { if (entries.some((e) => e.isIntersecting)) loadMore(); },
      { rootMargin: '800px 0px' },
    );
    io.observe(sentinelEl);
    return () => io.disconnect();
  }, [sentinelEl, loadMore]);

  const refresh = React.useCallback(() => {
    epochRef.current++; // invalidate any in-flight epoch
    offsetRef.current = 0;
    inflightRef.current = false;
    setItems([]);
    setHasMore(true);
    setLoading(true);
    const myEpoch = epochRef.current;
    fnRef.current(0, pageSize).then((r) => {
      if (epochRef.current !== myEpoch) return;
      setItems(r.items || []);
      setHasMore(!!r.hasMore);
      offsetRef.current = (r.items || []).length;
      setLoading(false);
    }).catch((e) => {
      if (epochRef.current !== myEpoch) return;
      setError(e);
      setLoading(false);
    });
  }, [pageSize]);

  return { items, loading, loadingMore, error, hasMore, total, sentinelRef, refresh, loadMore };
}

// ===========================================================================
// ModelAccuracyChart — per-model accuracy over time with vertical lines at
// version-change moments. Lets you eyeball "did the new version help?".
// Expects byModel = { modelId: [{ day, accuracy, predictions }, …] }
// and versionsByModel = { modelId: [{ modelVersion, startedAt, endedAt }, …] }
// ===========================================================================
function ModelAccuracyChart({ byModel, versionsByModel, days = 30, height = 280 }) {
  const { models, modelById } = useMeta();
  const W = 1100, padL = 56, padR = 24, padT = 24, padB = 32;
  const H = height;
  const innerW = W - padL - padR;
  const innerH = H - padT - padB;

  // Hover state — { dayMs, points: [{ modelId, modelLabel, color, accuracy }] }
  const [hover, setHover] = React.useState(null);
  const wrapRef = React.useRef(null);

  // Auto-fit the time window. If every data point is bunched up in the last
  // 2 days, plotting them against a 30-day axis makes the chart look empty
  // with everything mashed against the right edge. Zoom out only as wide as
  // we actually have data for (with a 12h pad on the left).
  const endMs = Date.now();
  const requestedSpan = days * 86_400_000;
  const earliestPoint = Object.values(byModel || {})
    .flat()
    .map((p) => new Date(p.day).getTime())
    .filter((n) => Number.isFinite(n))
    .reduce((min, n) => (min == null ? n : Math.min(min, n)), null);

  let startMs = endMs - requestedSpan;
  if (earliestPoint != null) {
    const dataSpan = endMs - earliestPoint + 12 * 3600 * 1000; // 12h left pad
    if (dataSpan < requestedSpan) {
      startMs = endMs - Math.max(dataSpan, 2 * 86_400_000); // never narrower than 2 days
    }
  }
  const activeDays = Math.max(1, Math.round((endMs - startMs) / 86_400_000));
  const xOf = (ms) => padL + ((ms - startMs) / (endMs - startMs)) * innerW;

  // Y axis: clip to 30–95%. Wide enough to fit any plausible accuracy.
  const yMin = 0.30, yMax = 0.95;
  const yOf = (v) => padT + (1 - (Math.max(yMin, Math.min(yMax, v)) - yMin) / (yMax - yMin)) * innerH;

  const yTicks = [0.4, 0.5, 0.6, 0.7, 0.8, 0.9];
  // Deduped + sorted day marks. For small windows (e.g. 2 days) we used to
  // emit [2, 1, 0, 0] which rendered "today" twice on the X axis.
  const dayMarks = Array.from(new Set([
    activeDays,
    Math.floor(activeDays * 2 / 3),
    Math.floor(activeDays / 3),
    0,
  ])).sort((a, b) => b - a);

  // Day-buckets with too few scored predictions are sample-size artefacts —
  // 1 right out of 1 is "100%" but isn't a real signal. Hiding them keeps
  // the chart honest. The leaderboard table still counts every prediction.
  const MIN_SAMPLE_PER_DAY = 3;
  function meaningful(p) {
    // total = scored count when accuracy is real ground-truth; agreement uses
    // total predictions, where small samples matter less. We gate on total.
    return (p.total || 0) >= MIN_SAMPLE_PER_DAY;
  }

  function linePath(series) {
    if (!series || !series.length) return '';
    return series
      .filter((p) => new Date(p.day).getTime() >= startMs && meaningful(p))
      .map((p, i) => {
        const x = xOf(new Date(p.day).getTime()).toFixed(1);
        const y = yOf(p.accuracy).toFixed(1);
        return `${i === 0 ? 'M' : 'L'}${x},${y}`;
      })
      .join(' ');
  }

  // Version-change markers: only REAL switches (a model's second-or-later
  // version), never the initial registration on first boot. We sort each
  // model's versions oldest-first and drop index 0.
  const rawMarkers = [];
  for (const [modelId, versionsRaw] of Object.entries(versionsByModel || {})) {
    const m = modelById(modelId);
    if (!m) continue;
    const versions = (versionsRaw || [])
      .slice()
      .sort((a, b) => new Date(a.startedAt) - new Date(b.startedAt));
    // Skip versions[0] — that's the initial registration, not a switch.
    for (let i = 1; i < versions.length; i++) {
      const v = versions[i];
      const ms = new Date(v.startedAt).getTime();
      if (ms <= startMs || ms > endMs) continue;
      rawMarkers.push({ ms, modelId, color: m.color, short: m.short, version: v.modelVersion });
    }
  }
  // De-stack markers that fall within ~5px of each other on the X axis.
  // Same model's rapid-fire switches get collapsed into "OPUS → v1 → v2".
  // Different models at the same instant share a vertical line but list
  // their labels stacked.
  rawMarkers.sort((a, b) => a.ms - b.ms);
  const markers = [];
  for (const m of rawMarkers) {
    const last = markers[markers.length - 1];
    if (last && Math.abs(xOf(m.ms) - xOf(last.ms)) < 6) {
      // Bucket together: same X cluster.
      last.entries.push(m);
    } else {
      markers.push({ ms: m.ms, entries: [m] });
    }
  }

  // Per-model legend value. Was "last day's accuracy" — but that swings wildly
  // on small daily samples and disagreed with the cumulative number shown in
  // the table below. Now: cumulative accuracy across the visible window
  // (sum-of-correct / sum-of-total), so legend % matches the table.
  const lasts = models
    .map((m) => {
      const series = byModel?.[m.id] || [];
      if (!series.length) return null;
      let correct = 0, total = 0;
      for (const p of series) { correct += (p.correct || 0); total += (p.total || 0); }
      const cumulative = total ? correct / total : (series[series.length - 1]?.accuracy ?? 0);
      return { model: m, last: { accuracy: cumulative, correct, total } };
    })
    .filter(Boolean);

  // All unique day timestamps in the window, sorted asc. Used to snap the
  // hover cursor to a real day-bucket rather than letting it float.
  const allDays = React.useMemo(() => {
    const set = new Set();
    for (const s of Object.values(byModel || {})) {
      for (const p of s) {
        const ms = new Date(p.day).getTime();
        if (ms >= startMs && ms <= endMs) set.add(ms);
      }
    }
    return [...set].sort((a, b) => a - b);
  }, [byModel, startMs, endMs]);

  function onMove(e) {
    if (!wrapRef.current || !allDays.length) return;
    const rect = wrapRef.current.getBoundingClientRect();
    // Convert pixel cursor → viewBox X (chart stretches via preserveAspectRatio="none")
    const cursorVbX = ((e.clientX - rect.left) / rect.width) * W;
    if (cursorVbX < padL || cursorVbX > W - padR) { setHover(null); return; }
    // Snap to nearest day
    let nearest = allDays[0];
    let bestDist = Infinity;
    for (const ms of allDays) {
      const d = Math.abs(xOf(ms) - cursorVbX);
      if (d < bestDist) { bestDist = d; nearest = ms; }
    }
    const points = [];
    for (const m of models) {
      const p = (byModel?.[m.id] || []).find((pt) => new Date(pt.day).getTime() === nearest);
      if (p) points.push({ modelId: m.id, modelLabel: m.label, short: m.short, color: m.color, accuracy: p.accuracy, predictions: p.predictions, total: p.total, correct: p.correct });
    }
    setHover({ dayMs: nearest, points });
  }
  function onLeave() { setHover(null); }

  // Build the tooltip position in pixels (over the wrapping div).
  const tooltip = (() => {
    if (!hover || !wrapRef.current) return null;
    const rect = wrapRef.current.getBoundingClientRect();
    const xVb = xOf(hover.dayMs);
    const left = (xVb / W) * rect.width;
    // Show on whichever side has room
    const flipRight = left > rect.width - 180;
    return { left, flipRight };
  })();

  return (
    <div className="agg-panel" ref={wrapRef} style={{ position: 'relative' }}
         onMouseMove={onMove} onMouseLeave={onLeave}>
      <svg className="agg-svg" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none">
        {/* Y grid */}
        {yTicks.map((t) => (
          <g key={t}>
            <line x1={padL} x2={W - padR} y1={yOf(t)} y2={yOf(t)} stroke="var(--border)" strokeWidth="1" />
            <text x={padL - 10} y={yOf(t) + 3.5} textAnchor="end" fill="var(--fg-faint)" fontFamily="JetBrains Mono, monospace" fontSize="10.5">
              {Math.round(t * 100)}%
            </text>
          </g>
        ))}
        {/* X axis labels — actual dates, e.g. "May 23" / "today". Relative
            offsets ("-1d") were faster to scan but hid which day you were
            looking at; the tooltip already shows the day, so the axis can
            carry the date too. */}
        {dayMarks.map((d) => {
          const ms = endMs - d * 86_400_000;
          const label = d === 0
            ? 'today'
            : new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
          return (
            <text key={d} x={xOf(ms)} y={H - padB + 18} textAnchor="middle" fill="var(--fg-faint)" fontFamily="JetBrains Mono, monospace" fontSize="10.5">
              {label}
            </text>
          );
        })}
        <text x={padL - 48} y={padT - 8} fill="var(--fg-faint)" fontFamily="JetBrains Mono, monospace" fontSize="9.5" letterSpacing="0.08em">↑ BETTER</text>

        {/* Version-change markers: subtle dashed verticals only — no inline
            labels. The full version history is in the "Model versions" table
            below the chart, so we'd just be duplicating that here while
            covering the chart with overlapping text when several models
            switch at once (e.g. a fresh deploy). */}
        {markers.map((bucket, bi) => {
          const x = xOf(bucket.ms);
          return (
            <line key={bi}
                  x1={x} x2={x} y1={padT} y2={H - padB}
                  stroke="var(--fg-faint)" strokeWidth="1"
                  strokeDasharray="3 5" opacity="0.35" />
          );
        })}

        {/* Per-model series. With ≥2 points we draw a line; with a single
            point we draw a bigger dot so the chart doesn't look empty.
            End-labels removed entirely — the legend below the chart lists
            each model with its current value, no overlap risk. */}
        {models.map((m) => {
          const series = (byModel?.[m.id] || []).filter((p) => new Date(p.day).getTime() >= startMs);
          if (!series.length) return null;
          const last = series[series.length - 1];
          const xLast = xOf(new Date(last.day).getTime());
          const yLast = yOf(last.accuracy);
          if (series.length === 1) {
            // Single data point — slightly larger dot, no line.
            return (
              <g key={m.id}>
                <circle cx={xLast} cy={yLast} r="5" fill={m.color} opacity={m.enabled ? 1 : 0.35} />
                <circle cx={xLast} cy={yLast} r="9" fill={m.color} opacity={m.enabled ? 0.18 : 0.08} />
              </g>
            );
          }
          const d = linePath(series);
          return (
            <g key={m.id}>
              <path d={d} stroke={m.color} strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" opacity={m.enabled ? 1 : 0.35} />
              <circle cx={xLast} cy={yLast} r="3.5" fill={m.color} />
            </g>
          );
        })}

        {/* Hover cursor — vertical line + a dot on each model that has a
            value on this day. Snapped to the nearest day-bucket. */}
        {hover && (
          <g>
            <line x1={xOf(hover.dayMs)} x2={xOf(hover.dayMs)} y1={padT} y2={H - padB}
                  stroke="var(--fg-dim)" strokeWidth="1" opacity="0.6" />
            {hover.points.map((p) => (
              <circle key={p.modelId}
                      cx={xOf(hover.dayMs)} cy={yOf(p.accuracy)}
                      r="4" fill={p.color}
                      stroke="var(--bg-elev)" strokeWidth="1.5" />
            ))}
          </g>
        )}
      </svg>

      {/* Tooltip overlay — absolute-positioned HTML so we get real fonts
          and easy formatting. Shows the actual date + each model's accuracy. */}
      {hover && tooltip && (
        <div
          style={{
            position: 'absolute',
            top: 24,
            left: tooltip.flipRight ? undefined : tooltip.left + 12,
            right: tooltip.flipRight ? `calc(100% - ${tooltip.left}px + 12px)` : undefined,
            background: 'var(--bg-elev-2)',
            border: '1px solid var(--border-strong)',
            borderRadius: 6,
            padding: '10px 12px',
            minWidth: 200,
            zIndex: 10,
            pointerEvents: 'none',
            boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
          }}
        >
          <div className="mono" style={{ fontSize: 11, color: 'var(--fg-dim)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>
            {new Date(hover.dayMs).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })}
          </div>
          {hover.points.length === 0 ? (
            <div className="mono faint" style={{ fontSize: 11 }}>No predictions this day.</div>
          ) : (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
              {hover.points
                .slice()
                .sort((a, b) => (b.accuracy || 0) - (a.accuracy || 0))
                .map((p) => (
                  <div key={p.modelId} style={{ display: 'grid', gridTemplateColumns: '10px 1fr auto', gap: 8, alignItems: 'center' }}>
                    <span style={{ width: 8, height: 8, borderRadius: '50%', background: p.color }} />
                    <span className="mono" style={{ fontSize: 11.5, color: 'var(--fg)' }}>{p.short || p.modelLabel}</span>
                    <span className="mono tnum" style={{ fontSize: 12, color: p.color, fontWeight: 500 }}>
                      {Math.round(p.accuracy * 100)}%
                    </span>
                  </div>
                ))}
            </div>
          )}
        </div>
      )}

      {/* Legend — one chip per model with last value. Replaces the overlapping
          in-chart end-labels. */}
      {lasts.length > 0 && (
        <div className="traj-legend">
          {lasts.map(({ model: m, last }) => (
            <span key={m.id} className="mchip" style={{ opacity: m.enabled ? 1 : 0.5 }}>
              <span className="mdot" style={{ background: m.color }} />
              <span style={{ color: 'var(--fg)' }}>{m.short || m.label}</span>
              <span className="mono tnum faint" style={{ marginLeft: 4 }}>{Math.round(last.accuracy * 100)}%</span>
            </span>
          ))}
        </div>
      )}
    </div>
  );
}

// ===========================================================================
// NextResolutionWidget — shows the very next market to resolve with a live
// countdown, and a small "more soon" strip. Reloads data every 30s; the
// countdown ticks every second purely client-side.
// ===========================================================================
function NextResolutionWidget({ navigate }) {
  const [data, setData] = useState(null);
  const [, setNow] = useState(Date.now()); // re-render every second for the countdown
  useEffect(() => {
    let alive = true;
    const load = () => {
      window.CloudLayerAPI.nextResolutions(5)
        .then((r) => { if (alive) setData(r); })
        .catch(() => {});
    };
    load();
    const dataT = setInterval(load, 30_000);
    const tickT = setInterval(() => setNow(Date.now()), 1000);
    return () => { alive = false; clearInterval(dataT); clearInterval(tickT); };
  }, []);

  if (!data || !data.items?.length) return null;

  const next = data.items[0];
  const more = data.items.slice(1, 4);
  const msLeft = Math.max(0, new Date(next.endDate).getTime() - Date.now());
  const cd = formatCountdown(msLeft);

  return (
    <div className="panel" style={{ padding: 0, overflow: 'hidden' }}>
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: 'minmax(0,1fr) auto',
          gap: 24,
          alignItems: 'center',
          padding: '14px 18px',
          borderBottom: '1px solid var(--border)',
          cursor: 'pointer',
        }}
        onClick={() => navigate('market', { id: next.polymarketId })}
      >
        <div style={{ minWidth: 0 }}>
          <div className="uppercase faint" style={{ marginBottom: 6 }}>
            Next resolution
          </div>
          <div
            style={{
              fontFamily: 'var(--ff-display)',
              fontSize: 17,
              fontWeight: 400,
              lineHeight: 1.25,
              color: 'var(--fg)',
              whiteSpace: 'nowrap',
              overflow: 'hidden',
              textOverflow: 'ellipsis',
            }}
            title={next.question}
          >
            {next.question}
          </div>
          <div className="mono faint" style={{ fontSize: 11, marginTop: 4 }}>
            {next.category} · market {Math.round((next.yesPrice ?? 0.5) * 100)}% YES
          </div>
        </div>
        <div style={{ textAlign: 'right' }}>
          <div className="mono tnum" style={{ fontSize: 28, color: 'var(--accent)', fontWeight: 500, lineHeight: 1 }}>
            {cd.value}
          </div>
          <div className="mono faint" style={{ fontSize: 10.5, marginTop: 4, letterSpacing: '0.1em', textTransform: 'uppercase' }}>
            {cd.unit}
          </div>
        </div>
      </div>
      {more.length > 0 && (
        <div style={{ padding: '10px 18px 12px' }}>
          <div className="mono faint" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 6 }}>
            then ({data.in24h} more in 24h · {data.in7d} in 7d)
          </div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
            {more.map((m) => {
              const ms = Math.max(0, new Date(m.endDate).getTime() - Date.now());
              const c = formatCountdown(ms);
              return (
                <div
                  key={m.polymarketId}
                  onClick={() => navigate('market', { id: m.polymarketId })}
                  style={{
                    display: 'grid',
                    gridTemplateColumns: '70px minmax(0, 1fr) auto',
                    gap: 12,
                    alignItems: 'center',
                    fontSize: 12.5,
                    cursor: 'pointer',
                    padding: '4px 0',
                  }}
                >
                  <span className="mono faint" style={{ fontSize: 11 }}>
                    +{c.value}{c.shortUnit}
                  </span>
                  <span style={{ color: 'var(--fg-dim)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={m.question}>
                    {m.question}
                  </span>
                  <span className="cat-pill" style={{ fontSize: 9.5 }}>{m.category}</span>
                </div>
              );
            })}
          </div>
        </div>
      )}
    </div>
  );
}

function formatCountdown(ms) {
  if (ms <= 0) return { value: 'now', unit: 'any moment', shortUnit: 's' };
  const totalSec = Math.floor(ms / 1000);
  const d = Math.floor(totalSec / 86400);
  const h = Math.floor((totalSec % 86400) / 3600);
  const m = Math.floor((totalSec % 3600) / 60);
  const s = totalSec % 60;
  if (d > 0) return { value: `${d}d ${h}h`, unit: 'until next', shortUnit: 'd' };
  if (h > 0) return { value: `${h}h ${m}m`, unit: 'until next', shortUnit: 'h' };
  if (m > 0) return { value: `${m}m ${String(s).padStart(2, '0')}s`, unit: 'until next', shortUnit: 'm' };
  return { value: `${s}s`, unit: 'until next', shortUnit: 's' };
}

// ===========================================================================
// SearchInput — debounced, controlled. value/onChange flow normally, but the
// onCommit fires only after `delay` ms of stillness — that's the value to
// hand to your API call so you don't fire one request per keystroke.
// ===========================================================================
function SearchInput({ value, onChange, onCommit, delay = 280, placeholder = 'Search…', width = 280 }) {
  // The committed value is debounced from the typing value.
  useEffect(() => {
    const t = setTimeout(() => onCommit(value.trim()), delay);
    return () => clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [value, delay]);
  return (
    <div className="search" style={{ width, flexShrink: 0 }}>
      <span style={{ color: 'var(--fg-faint)' }}>⌕</span>
      <input
        placeholder={placeholder}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === 'Escape') {
            onChange('');
            onCommit('');
          }
        }}
      />
      {value && (
        <button
          type="button"
          onClick={() => { onChange(''); onCommit(''); }}
          style={{ background: 'transparent', border: 0, color: 'var(--fg-faint)', cursor: 'pointer', fontSize: 14, padding: 0 }}
          title="Clear (Esc)"
        >×</button>
      )}
    </div>
  );
}

// ===========================================================================
// ConditionsBuilder — popover with one row per model × four states:
//   YES / NO / ✓ right / ✗ wrong / (clear)
// The state shape is { [modelId]: 'YES' | 'NO' | 'right' | 'wrong' | null }.
// Picking 'right' or 'wrong' implicitly switches the view to resolved markets
// (the backend enforces this), so the UI shows a hint when that happens.
// Returned to the parent as a single string ("grok:YES,gpt:NO") via onCommit.
// ===========================================================================
function ConditionsBuilder({ value, onChange, modelsList }) {
  // value: same shape as state above. Parent owns the source of truth.
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    if (!open) return;
    const onClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', onClick);
    return () => document.removeEventListener('mousedown', onClick);
  }, [open]);

  const set = (modelId, cond) => {
    const next = { ...value };
    if (next[modelId] === cond) delete next[modelId];
    else next[modelId] = cond;
    onChange(next);
  };
  const clear = () => onChange({});

  const activeCount = Object.values(value).filter(Boolean).length;
  const needsResolved = Object.values(value).some((v) => v === 'right' || v === 'wrong');

  return (
    <div ref={ref} style={{ position: 'relative' }}>
      <button
        type="button"
        className={`fchip ${activeCount ? 'active' : ''}`}
        onClick={() => setOpen((o) => !o)}
        style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}
        title="Build a multi-model filter"
      >
        ⚙ Conditions
        {activeCount > 0 && (
          <span className="mono" style={{ fontSize: 10, opacity: 0.8 }}>{activeCount}</span>
        )}
      </button>

      {open && (
        <div
          className="panel"
          style={{
            position: 'absolute',
            top: 'calc(100% + 8px)',
            right: 0,
            minWidth: 360,
            background: 'var(--bg-elev)',
            border: '1px solid var(--border-strong)',
            borderRadius: 6,
            zIndex: 60,
            boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
            padding: '14px 16px',
          }}
        >
          <div className="between" style={{ marginBottom: 10 }}>
            <span className="uppercase faint">Filter by model calls</span>
            {activeCount > 0 && (
              <button
                type="button"
                onClick={clear}
                className="mono"
                style={{ fontSize: 10.5, color: 'var(--fg-faint)', background: 'transparent', border: 0, cursor: 'pointer', padding: 0 }}
              >Clear all</button>
            )}
          </div>

          <div style={{ display: 'grid', gap: 6 }}>
            {modelsList.map((m) => {
              const cur = value[m.id] || null;
              return (
                <div key={m.id} style={{ display: 'grid', gridTemplateColumns: '110px 1fr', gap: 10, alignItems: 'center' }}>
                  <span style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 12 }}>
                    <span style={{ width: 8, height: 8, borderRadius: '50%', background: m.color, opacity: m.enabled ? 1 : 0.35 }} />
                    <span className="mono" style={{ color: 'var(--fg)' }}>{m.short || m.label}</span>
                  </span>
                  <div className="filter-row" style={{ gap: 4 }}>
                    {[
                      { v: 'YES',   label: 'YES',   color: 'var(--pos)' },
                      { v: 'NO',    label: 'NO',    color: 'var(--neg)' },
                      { v: 'right', label: '✓ right', color: 'var(--pos)' },
                      { v: 'wrong', label: '✗ wrong', color: 'var(--neg)' },
                    ].map((opt) => {
                      const active = cur === opt.v;
                      return (
                        <button
                          key={opt.v}
                          type="button"
                          className={`fchip ${active ? 'active' : ''}`}
                          onClick={() => set(m.id, opt.v)}
                          style={{
                            fontSize: 10.5,
                            padding: '3px 8px',
                            borderColor: active ? opt.color : undefined,
                            color: active ? opt.color : undefined,
                          }}
                        >{opt.label}</button>
                      );
                    })}
                  </div>
                </div>
              );
            })}
          </div>

          <div className="mono faint" style={{ fontSize: 10.5, marginTop: 12, lineHeight: 1.5 }}>
            {needsResolved
              ? <>Picking <em style={{ fontStyle: 'normal', color: 'var(--fg-dim)' }}>right/wrong</em> implies resolved markets — the grid will switch.</>
              : <>Picking <em style={{ fontStyle: 'normal', color: 'var(--fg-dim)' }}>YES/NO</em> matches each model's latest verdict, in any state.</>}
            <br />
            Conditions combine with <strong style={{ color: 'var(--fg)' }}>AND</strong>. A market must satisfy every row to make the list.
          </div>
        </div>
      )}
    </div>
  );
}

// Serialize the conditions object → query-string format the backend expects.
function serializeConditions(value) {
  return Object.entries(value || {})
    .filter(([, v]) => v)
    .map(([k, v]) => `${k}:${v}`)
    .join(',');
}

// Small money formatter shared across pages.
function formatMoney(n) {
  if (!Number.isFinite(n)) return '$0';
  if (n >= 1e9) return `$${(n / 1e9).toFixed(1)}B`;
  if (n >= 1e6) return `$${(n / 1e6).toFixed(1)}M`;
  if (n >= 1e3) return `$${(n / 1e3).toFixed(0)}K`;
  return `$${Math.round(n)}`;
}

// ===========================================================================
// AccuracyLineChart — generic per-series line chart. Powers per-prompt detail
// (one series) and the Community leaderboard chart (top-N prompts). Mirrors
// the visual language of ModelAccuracyChart but doesn't depend on the
// `models` registry, so anything with a color + points works.
// Expects series = [{ id, label, color, points: [{ day, accuracy, predictions, total, correct }] }]
// ===========================================================================
function AccuracyLineChart({ series, days = 30, height = 240, showLegend = true }) {
  const W = 1100, padL = 56, padR = 24, padT = 20, padB = 30;
  const H = height;
  const innerW = W - padL - padR;
  const innerH = H - padT - padB;

  const [hover, setHover] = React.useState(null);
  const wrapRef = React.useRef(null);

  // Same auto-fit logic as ModelAccuracyChart — zoom out only as wide as the
  // data actually spans.
  const endMs = Date.now();
  const requestedSpan = days * 86_400_000;
  const all = (series || []).flatMap((s) => s.points || []);
  const earliestPoint = all
    .map((p) => new Date(p.day).getTime())
    .filter(Number.isFinite)
    .reduce((min, n) => (min == null ? n : Math.min(min, n)), null);

  let startMs = endMs - requestedSpan;
  if (earliestPoint != null) {
    const dataSpan = endMs - earliestPoint + 12 * 3600 * 1000;
    if (dataSpan < requestedSpan) {
      startMs = endMs - Math.max(dataSpan, 2 * 86_400_000);
    }
  }
  const xOf = (ms) => padL + ((ms - startMs) / (endMs - startMs)) * innerW;

  const yMin = 0.30, yMax = 0.95;
  const yOf = (a) => padT + (1 - (Math.max(yMin, Math.min(yMax, a)) - yMin) / (yMax - yMin)) * innerH;

  function pathFor(points) {
    const inRange = (points || [])
      .filter((p) => {
        const t = new Date(p.day).getTime();
        return Number.isFinite(t) && t >= startMs && t <= endMs;
      })
      .sort((a, b) => new Date(a.day) - new Date(b.day));
    if (inRange.length === 0) return { d: '', last: null };
    const cmds = inRange.map((p, i) => {
      const x = xOf(new Date(p.day).getTime());
      const y = yOf(p.accuracy);
      return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
    });
    const last = inRange[inRange.length - 1];
    return { d: cmds.join(' '), last: { x: xOf(new Date(last.day).getTime()), y: yOf(last.accuracy) } };
  }

  function onMove(e) {
    if (!wrapRef.current || all.length === 0) return;
    const rect = wrapRef.current.getBoundingClientRect();
    const x = ((e.clientX - rect.left) / rect.width) * W;
    if (x < padL || x > W - padR) { setHover(null); return; }
    const t = startMs + ((x - padL) / innerW) * (endMs - startMs);
    // Nearest day across all series
    const candidates = all.map((p) => new Date(p.day).getTime()).filter(Number.isFinite);
    const nearest = candidates.reduce((best, n) => Math.abs(n - t) < Math.abs(best - t) ? n : best, candidates[0]);
    const points = (series || []).map((s) => {
      const p = (s.points || []).find((pt) => new Date(pt.day).getTime() === nearest);
      return p ? { id: s.id, label: s.label, color: s.color, ...p } : null;
    }).filter(Boolean);
    if (!points.length) { setHover(null); return; }
    setHover({ dayMs: nearest, points });
  }

  if (!series || series.length === 0 || all.length === 0) {
    return (
      <div className="dim" style={{ padding: 24, textAlign: 'center', fontSize: 13 }}>
        No accuracy data yet — predictions need to resolve first.
      </div>
    );
  }

  return (
    <div ref={wrapRef} onMouseMove={onMove} onMouseLeave={() => setHover(null)}
         style={{ position: 'relative', width: '100%' }}>
      <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ width: '100%', height: H }}>
        {/* Y gridlines at 40 / 60 / 80 % */}
        {[0.4, 0.6, 0.8].map((yv) => (
          <g key={yv}>
            <line x1={padL} x2={W - padR} y1={yOf(yv)} y2={yOf(yv)} stroke="var(--line)" strokeDasharray="2 4" />
            <text x={padL - 8} y={yOf(yv) + 4} textAnchor="end" fontFamily="var(--ff-mono, monospace)" fontSize="10" fill="var(--fg-dim)">
              {Math.round(yv * 100)}%
            </text>
          </g>
        ))}
        {/* X axis dates */}
        {[0, 0.5, 1].map((f) => {
          const ms = startMs + f * (endMs - startMs);
          const d = new Date(ms);
          const x = padL + f * innerW;
          return (
            <text key={f} x={x} y={H - 8} textAnchor={f === 0 ? 'start' : f === 1 ? 'end' : 'middle'}
                  fontFamily="var(--ff-mono, monospace)" fontSize="10" fill="var(--fg-dim)">
              {d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
            </text>
          );
        })}
        {/* Lines */}
        {(series || []).map((s) => {
          const { d, last } = pathFor(s.points);
          if (!d) return null;
          return (
            <g key={s.id}>
              <path d={d} stroke={s.color} strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" />
              {last && <circle cx={last.x} cy={last.y} r="3.5" fill={s.color} />}
            </g>
          );
        })}
        {/* Hover crosshair */}
        {hover && (
          <g>
            <line x1={xOf(hover.dayMs)} x2={xOf(hover.dayMs)} y1={padT} y2={H - padB}
                  stroke="var(--accent)" strokeWidth="0.8" opacity="0.4" />
            {hover.points.map((p) => (
              <circle key={p.id} cx={xOf(hover.dayMs)} cy={yOf(p.accuracy)} r="4" fill={p.color}
                      stroke="var(--bg)" strokeWidth="1.5" />
            ))}
          </g>
        )}
      </svg>
      {hover && (
        <div style={{
          position: 'absolute', top: 10,
          left: xOf(hover.dayMs) > W * 0.6 ? '8px' : 'auto',
          right: xOf(hover.dayMs) > W * 0.6 ? 'auto' : '8px',
          background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 6,
          padding: '8px 12px', fontSize: 11, pointerEvents: 'none', minWidth: 180,
        }}>
          <div className="mono" style={{ fontSize: 10, color: 'var(--fg-dim)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 6 }}>
            {new Date(hover.dayMs).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })}
          </div>
          {hover.points.map((p) => (
            <div key={p.id} style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 3 }}>
              <span style={{ width: 8, height: 8, borderRadius: '50%', background: p.color }} />
              <span style={{ fontSize: 11.5, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.label}</span>
              <span className="mono tnum" style={{ fontSize: 11.5, fontWeight: 600 }}>{Math.round(p.accuracy * 100)}%</span>
            </div>
          ))}
        </div>
      )}
      {showLegend && series.length > 1 && (
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8, fontSize: 11 }}>
          {series.map((s) => (
            <div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
              <span style={{ width: 8, height: 8, borderRadius: '50%', background: s.color }} />
              <span className="mono">{s.label}</span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Object.assign(window, {
  Sparkline, MDot, MChip, PredRow, OutcomeCell,
  PageHead, Tag, CopyButton, PromptBlock,
  TrajectoryChart, LivePill, FilterChips,
  MarketCard, CategoryTabs, MarketGrid,
  AggregateChart,
  useMeta, useAuth, useApi, useInfiniteList, Loading, LoadingBar, ApiError,
  LiveMarketCard, LiveMarketGrid,
  SignInButton, UserMenu, CategoryDropdown,
  ModelAccuracyChart, MarketTrajectory, AccuracyLineChart,
  NextResolutionWidget,
  SearchInput,
  ConditionsBuilder, serializeConditions,
  formatMoney,
});
