/* Assistant (admin + members): a chat drawer on the right that answers questions about the
 * traders, their calls and the market, and reviews a trader's ledger.
 *
 * Opened from the top bar (✦ Ask, or "/"), from a strategy page ("Review ledger"), or from a
 * ledger row ("Ask about this trade") via window.dispatchEvent(new CustomEvent('open-assistant',
 * { detail: { kind, strategy_id, signal_id, prompt, autoSend } })).
 *
 * The answer streams from POST /api/assistant/chat as server-sent events: text deltas, tool
 * steps (shown as small chips), and artifacts (charts, fix cards) drawn inline. Markdown is
 * rendered by a small renderer below that only ever builds React elements (no HTML strings).
 */
const { useState: useStateAS, useEffect: useEffectAS, useRef: useRefAS, useMemo: useMemoAS, useCallback: useCallbackAS } = React;

const AS_CSS = `
.as-drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 460px; max-width: 100vw; z-index: 60;
  background: var(--bg-app); border-left: 1px solid var(--border); display: flex; flex-direction: column;
  box-shadow: -24px 0 60px rgba(0,0,0,0.45); transform: translateX(100%); transition: transform .26s cubic-bezier(.2,.8,.2,1); }
.as-drawer.open { transform: none; }
.as-drawer.wide { width: 720px; }
@media (max-width: 760px) { .as-drawer, .as-drawer.wide { width: 100vw; } }
.as-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border-soft); }
.as-title { font-weight: 600; font-size: 13.5px; display: flex; align-items: center; gap: 7px; }
.as-scope { font-size: 10.5px; color: var(--text-3); padding: 2px 8px; border: 1px solid var(--border-soft); border-radius: 99px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 190px; }
.as-iconbtn { background: transparent; border: 1px solid transparent; color: var(--text-3); cursor: pointer; border-radius: 6px; padding: 4px 7px; font-size: 12px; font-family: var(--font-sans); }
.as-iconbtn:hover { color: var(--text); background: var(--bg-hover); }
.as-body { flex: 1; overflow-y: auto; padding: 14px 16px 6px; scroll-behavior: smooth; }
.as-q { margin: 14px 0 10px auto; max-width: 88%; width: fit-content; background: var(--bg-elev); border: 1px solid var(--border-soft);
  padding: 8px 12px; border-radius: 12px 12px 3px 12px; font-size: 13px; white-space: pre-wrap; line-height: 1.5; }
.as-a { font-size: 13px; line-height: 1.6; color: var(--text); margin-bottom: 8px; }
.as-a p { margin: 0 0 9px; }
.as-a h3, .as-a h4 { font-size: 13px; margin: 12px 0 6px; font-weight: 650; }
.as-a ul, .as-a ol { margin: 0 0 9px; padding-left: 20px; }
.as-a li { margin: 2px 0; }
.as-a code { font-family: var(--font-mono); font-size: 11.5px; background: var(--bg-card-2); padding: 1px 5px; border-radius: 4px; }
.as-a table { border-collapse: collapse; margin: 6px 0 12px; font-size: 11.5px; width: 100%; }
.as-tbl { overflow-x: auto; }
.as-a th, .as-a td { border-bottom: 1px solid var(--border-soft); padding: 5px 7px; text-align: left; vertical-align: top; }
.as-a td:first-child, .as-a th { white-space: nowrap; }
.as-a th { color: var(--text-3); font-weight: 600; font-size: 11px; }
.as-a blockquote { margin: 4px 0 10px; padding: 4px 10px; border-left: 2px solid var(--gold-dim, var(--gold)); color: var(--text-2); }
.as-sig { color: var(--gold); cursor: pointer; font-family: var(--font-mono); font-size: 12px; border-bottom: 1px dotted var(--gold); }
.as-steps { display: flex; flex-wrap: wrap; gap: 5px; margin: 6px 0 10px; }
.as-step { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-3); background: var(--bg-card);
  border: 1px solid var(--border-soft); border-radius: 99px; padding: 3px 9px 3px 7px; animation: as-in .25s ease-out; }
.as-step .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--gold); animation: as-pulse 1s ease-in-out infinite; }
.as-step.done .dot { animation: none; background: var(--bull); }
.as-step.fail .dot { animation: none; background: var(--bear); }
@keyframes as-pulse { 50% { opacity: .3; transform: scale(.75); } }
@keyframes as-in { from { opacity: 0; transform: translateY(3px); } }
.as-caret { display: inline-block; width: 7px; height: 14px; background: var(--gold); margin-left: 2px; vertical-align: -2px; animation: as-blink 1s steps(2) infinite; }
@keyframes as-blink { 50% { opacity: 0; } }
.as-notice { font-size: 11.5px; color: var(--gold); margin: 6px 0 10px; }
.as-mode { background: var(--bg-card); color: var(--text-2); border: 1px solid var(--border-soft); border-radius: 6px; font: 600 11px var(--font-sans); padding: 3px 6px; cursor: pointer; }
.as-mode:focus { outline: none; border-color: var(--gold); }
.as-long { display: flex; gap: 8px; align-items: center; margin: 0 12px 6px; padding: 7px 10px; border: 1px solid var(--border-soft); border-left: 3px solid var(--gold); border-radius: 8px; background: var(--bg-card); font-size: 11.5px; color: var(--text-2); }
.as-long button { margin-left: auto; white-space: nowrap; }
.as-err { font-size: 12px; color: var(--bear); margin: 6px 0 10px; }
.as-foot { border-top: 1px solid var(--border-soft); padding: 10px 12px 10px; }
.as-input { display: flex; gap: 8px; align-items: flex-end; background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; padding: 6px 6px 6px 10px; }
.as-input:focus-within { border-color: var(--gold); }
.as-input textarea { flex: 1; resize: none; background: transparent; border: none; outline: none; color: var(--text); font: 13px/1.45 var(--font-sans); max-height: 140px; padding: 4px 0; }
.as-send { background: var(--gold); color: #1b1408; border: none; border-radius: 7px; padding: 6px 12px; font-weight: 700; cursor: pointer; font-size: 12px; font-family: var(--font-sans); }
.as-send:disabled { opacity: .4; cursor: default; }
.as-meta { display: flex; justify-content: space-between; font-size: 10.5px; color: var(--text-4); margin-top: 6px; }
.as-meter { height: 3px; border-radius: 2px; background: var(--bg-elev); margin-top: 4px; overflow: hidden; }
.as-meter > i { display: block; height: 100%; background: var(--gold); }
.as-starters { display: grid; gap: 7px; margin-top: 10px; }
.as-starter { text-align: left; background: var(--bg-card); border: 1px solid var(--border-soft); border-radius: 9px; padding: 9px 11px; cursor: pointer;
  color: var(--text-2); font: 12.5px/1.4 var(--font-sans); transition: border-color .15s, transform .15s; }
.as-starter:hover { border-color: var(--gold); color: var(--text); transform: translateX(2px); }
.as-hero { padding: 18px 4px 6px; }
.as-hero h2 { font-size: 17px; margin: 8px 0 4px; letter-spacing: -0.01em; }
.as-hero p { font-size: 12.5px; color: var(--text-3); margin: 0; line-height: 1.55; }
.as-spark { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; background: linear-gradient(135deg, var(--gold), oklch(0.62 0.15 30)); color: #1b1408; font-size: 16px; }
.as-hist { position: absolute; top: 49px; left: 0; right: 0; bottom: 0; background: var(--bg-app); z-index: 2; overflow-y: auto; padding: 10px 12px; }
.as-hrow { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; cursor: pointer; font-size: 12.5px; }
.as-hrow:hover { background: var(--bg-hover); }
.as-hrow .t { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.as-hrow .d { font-size: 10.5px; color: var(--text-4); }
.as-chart { margin: 4px 0 12px; border: 1px solid var(--border-soft); border-radius: 10px; background: var(--bg-card); padding: 8px 8px 4px; }
.as-chart-h { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-3); margin: 0 4px 4px; gap: 8px; }
.as-fix { margin: 6px 0 12px; border: 1px solid var(--border); border-left: 3px solid var(--gold); border-radius: 10px; background: var(--bg-card); padding: 10px 12px; animation: as-in .3s ease-out; }
.as-fix.applied { border-left-color: var(--bull); }
.as-fix.dismissed { opacity: .55; border-left-color: var(--text-4); }
.as-fix.failed { border-left-color: var(--bear); }
.as-fix-t { font-weight: 650; font-size: 12.5px; display: flex; gap: 8px; align-items: baseline; }
.as-fix-x { font-size: 12px; color: var(--text-2); margin: 5px 0 6px; line-height: 1.5; }
.as-fix-ev { font-size: 11px; color: var(--text-3); margin: 0 0 8px; padding-left: 14px; }
.as-change { font-family: var(--font-mono); font-size: 11px; background: var(--bg-card-2); border-radius: 6px; padding: 6px 8px; margin-bottom: 8px; color: var(--text-2); }
.as-fix-a { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.as-btn { background: transparent; border: 1px solid var(--border); color: var(--text-2); border-radius: 6px; padding: 4px 10px; font: 600 11.5px var(--font-sans); cursor: pointer; }
.as-btn:hover { color: var(--text); border-color: var(--text-3); }
.as-btn.primary { border-color: var(--gold); color: var(--gold); }
.as-btn.primary.go { background: var(--gold); color: #1b1408; }
.as-btn:disabled { opacity: .45; cursor: default; }
.as-ask-btn { display: flex; align-items: center; gap: 6px; background: linear-gradient(135deg, oklch(0.55 0.13 60 / 0.25), oklch(0.5 0.12 30 / 0.2));
  border: 1px solid var(--gold); color: var(--gold); border-radius: 6px; padding: 6px 11px; font: 600 11.5px var(--font-sans); cursor: pointer; }
.as-over > div { z-index: 80 !important; }
.as-ask-btn:hover { background: oklch(0.55 0.13 60 / 0.32); }
@media (prefers-reduced-motion: reduce) { .as-drawer { transition: none; } .as-step, .as-fix { animation: none; } .as-step .dot, .as-caret { animation: none; } }
`;

function openAssistant(detail) {
  window.dispatchEvent(new CustomEvent('open-assistant', { detail: detail || {} }));
}

// ── markdown (React elements only) ─────────────────────────────────────────
// Inline: **bold**, *em*, `code`, #123 trade links.
function mdInline(text, onSignal, keyBase) {
  const out = [];
  const re = /(\*\*[^*]+\*\*|`[^`]+`|\*[^*\s][^*]*\*|#\d{2,7}\b)/g;
  let last = 0, m, i = 0;
  while ((m = re.exec(text))) {
    if (m.index > last) out.push(text.slice(last, m.index));
    const tok = m[0];
    const k = keyBase + '-' + (i++);
    if (tok.startsWith('**')) out.push(<b key={k}>{tok.slice(2, -2)}</b>);
    else if (tok.startsWith('`')) out.push(<code key={k}>{tok.slice(1, -1)}</code>);
    else if (tok.startsWith('#')) {
      const id = Number(tok.slice(1));
      out.push(onSignal ? <span key={k} className="as-sig" onClick={() => onSignal(id)} title="Open this trade's timeline">{tok}</span> : tok);
    } else out.push(<em key={k}>{tok.slice(1, -1)}</em>);
    last = m.index + tok.length;
  }
  if (last < text.length) out.push(text.slice(last));
  return out;
}

function Markdown({ text, onSignal }) {
  const blocks = useMemoAS(() => {
    const lines = String(text || '').replace(/\r/g, '').split('\n');
    const out = [];
    let i = 0;
    while (i < lines.length) {
      const l = lines[i];
      if (!l.trim()) { i++; continue; }
      if (/^\s*\|.*\|\s*$/.test(l) && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) {
        const row = s => s.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim());
        const head = row(l); i += 2;
        const body = [];
        while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) body.push(row(lines[i++]));
        out.push({ t: 'table', head, body });
        continue;
      }
      const h = l.match(/^(#{1,4})\s+(.*)$/);
      if (h) { out.push({ t: 'h', text: h[2] }); i++; continue; }
      if (/^\s*([-*•]|\d+[.)])\s+/.test(l)) {
        const ordered = /^\s*\d/.test(l);
        const items = [];
        while (i < lines.length && /^\s*([-*•]|\d+[.)])\s+/.test(lines[i])) items.push(lines[i++].replace(/^\s*([-*•]|\d+[.)])\s+/, ''));
        out.push({ t: ordered ? 'ol' : 'ul', items });
        continue;
      }
      if (/^>\s?/.test(l)) {
        const q = [];
        while (i < lines.length && /^>\s?/.test(lines[i])) q.push(lines[i++].replace(/^>\s?/, ''));
        out.push({ t: 'quote', text: q.join(' ') });
        continue;
      }
      const para = [];
      while (i < lines.length && lines[i].trim() && !/^(#{1,4}\s|>|\s*([-*•]|\d+[.)])\s+|\s*\|)/.test(lines[i])) para.push(lines[i++]);
      if (!para.length) para.push(lines[i++]);
      out.push({ t: 'p', text: para.join('\n') });
    }
    return out;
  }, [text]);
  return (
    <div className="as-a">
      {blocks.map((b, k) => {
        if (b.t === 'h') return <h4 key={k}>{mdInline(b.text, onSignal, k)}</h4>;
        if (b.t === 'ul') return <ul key={k}>{b.items.map((x, j) => <li key={j}>{mdInline(x, onSignal, k + '.' + j)}</li>)}</ul>;
        if (b.t === 'ol') return <ol key={k}>{b.items.map((x, j) => <li key={j}>{mdInline(x, onSignal, k + '.' + j)}</li>)}</ol>;
        if (b.t === 'quote') return <blockquote key={k}>{mdInline(b.text, onSignal, k)}</blockquote>;
        if (b.t === 'table') return (
          <div className="as-tbl" key={k}><table><thead><tr>{b.head.map((c, j) => <th key={j}>{mdInline(c, onSignal, k + 'h' + j)}</th>)}</tr></thead>
            <tbody>{b.body.map((r, j) => <tr key={j}>{r.map((c, x) => <td key={x}>{mdInline(c, onSignal, k + 'r' + j + x)}</td>)}</tr>)}</tbody></table></div>
        );
        return <p key={k}>{b.text.split('\n').map((ln, j) => <React.Fragment key={j}>{j > 0 && <br />}{mdInline(ln, onSignal, k + 'p' + j)}</React.Fragment>)}</p>;
      })}
    </div>
  );
}

// ── chart artifact ─────────────────────────────────────────────────────────
const AS_OVERLAY_COLORS = ['var(--gold)', 'var(--info)', 'var(--violet)', 'oklch(0.7 0.12 160)'];
function fmtPx(v) {
  if (v == null || !isFinite(v)) return '—';
  const a = Math.abs(v);
  return a >= 1000 ? v.toLocaleString(undefined, { maximumFractionDigits: 0 }) : a >= 1 ? v.toFixed(2) : v.toPrecision(4);
}
function AssistantChart({ chart }) {
  const wrap = useRefAS(null);
  const [w, setW] = useStateAS(420);
  const [hover, setHover] = useStateAS(null);
  useEffectAS(() => {
    if (!wrap.current) return undefined;
    const ro = new ResizeObserver(es => setW(Math.max(260, es[0].contentRect.width)));
    ro.observe(wrap.current);
    return () => ro.disconnect();
  }, []);
  const cs = chart.candles || [];
  if (!cs.length) return null;
  const paneKey = Object.keys(chart.pane || {})[0];
  const H = 210, PH = paneKey ? 64 : 0, padL = 4, padR = 54, padT = 8, gap = paneKey ? 10 : 0;
  const innerW = w - padL - padR;
  const lows = cs.map(c => c[3]), highs = cs.map(c => c[2]);
  const lv = (chart.levels || []).map(l => l.price);
  let lo = Math.min(...lows, ...lv), hi = Math.max(...highs, ...lv);
  const padY = (hi - lo) * 0.06 || hi * 0.01;
  lo -= padY; hi += padY;
  const x = i => padL + (cs.length === 1 ? innerW / 2 : (i / (cs.length - 1)) * innerW);
  const y = v => padT + (1 - (v - lo) / (hi - lo)) * (H - padT - 16);
  const bw = Math.max(1, Math.min(9, innerW / cs.length * 0.7));
  const t0 = cs[0][0], t1 = cs[cs.length - 1][0];
  const idxOf = t => { let best = 0, d = Infinity; cs.forEach((c, i) => { const dd = Math.abs(c[0] - t); if (dd < d) { d = dd; best = i; } }); return best; };
  const line = arr => arr.map((v, i) => (v == null ? null : [x(i), y(v)])).reduce((acc, p) => {
    if (!p) { acc.pen = false; return acc; }
    acc.d += (acc.pen ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1); acc.pen = true; return acc;
  }, { d: '', pen: false }).d;
  const overlays = [];
  Object.entries(chart.overlays || {}).forEach(([k, v]) => {
    if (Array.isArray(v)) overlays.push([k, v]);
    else Object.entries(v).forEach(([sub, arr]) => overlays.push([k + '.' + sub, arr]));
  });
  const pane = paneKey ? chart.pane[paneKey] : null;
  const paneSeries = pane ? (Array.isArray(pane) ? [[paneKey, pane]] : Object.entries(pane).filter(([k]) => k !== 'signal').map(([k, a]) => [k, a])) : [];
  const pv = paneSeries.flatMap(([, a]) => a.filter(v => v != null));
  const pLo = paneKey && paneKey.startsWith('rsi') ? 0 : Math.min(0, ...pv), pHi = paneKey && paneKey.startsWith('rsi') ? 100 : Math.max(0, ...pv);
  const py = v => H + gap + (1 - (v - pLo) / ((pHi - pLo) || 1)) * PH;
  const dayFmt = t => new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
  const span = t1 - t0;
  const tick = t => (span <= 2 * 864e5 ? new Date(t).toISOString().slice(11, 16) : dayFmt(t));
  const onMove = e => {
    const r = e.currentTarget.getBoundingClientRect();
    const i = Math.round(((e.clientX - r.left - padL) / innerW) * (cs.length - 1));
    setHover(i >= 0 && i < cs.length ? i : null);
  };
  const hc = hover != null ? cs[hover] : cs[cs.length - 1];
  const chg = ((cs[cs.length - 1][4] / cs[0][1]) - 1) * 100;
  return (
    <div className="as-chart" ref={wrap}>
      <div className="as-chart-h">
        <span style={{ color: 'var(--text-2)', fontWeight: 600 }}>{chart.title}</span>
        <span className="mono">
          {hover != null ? `${new Date(hc[0]).toISOString().slice(0, 16).replace('T', ' ')} · O ${fmtPx(hc[1])} H ${fmtPx(hc[2])} L ${fmtPx(hc[3])} C ${fmtPx(hc[4])}`
            : <>{fmtPx(cs[cs.length - 1][4])} <span style={{ color: chg >= 0 ? 'var(--bull)' : 'var(--bear)' }}>{chg >= 0 ? '+' : ''}{chg.toFixed(2)}%</span></>}
        </span>
      </div>
      <svg width={w} height={H + gap + PH + 4} style={{ display: 'block' }} onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
        {[0.25, 0.5, 0.75].map(f => <line key={f} x1={padL} x2={padL + innerW} y1={padT + f * (H - padT - 16)} y2={padT + f * (H - padT - 16)} stroke="var(--border-soft)" strokeDasharray="2 4" />)}
        {[0, 0.5, 1].map(f => { const v = hi - f * (hi - lo); return <text key={f} x={w - padR + 6} y={padT + f * (H - padT - 16) + 3} fontSize="9.5" fill="var(--text-4)" fontFamily="var(--font-mono)">{fmtPx(v)}</text>; })}
        {cs.map((c, i) => {
          const up = c[4] >= c[1];
          const col = up ? 'var(--bull)' : 'var(--bear)';
          return (
            <g key={i}>
              <line x1={x(i)} x2={x(i)} y1={y(c[2])} y2={y(c[3])} stroke={col} strokeWidth="1" opacity="0.8" />
              <rect x={x(i) - bw / 2} width={bw} y={Math.min(y(c[1]), y(c[4]))} height={Math.max(1, Math.abs(y(c[1]) - y(c[4])))} fill={col} opacity={up ? 0.85 : 0.9} />
            </g>
          );
        })}
        {overlays.map(([k, arr], j) => <path key={k} d={line(arr)} fill="none" stroke={AS_OVERLAY_COLORS[j % AS_OVERLAY_COLORS.length]} strokeWidth="1.3" opacity={k.endsWith('.mid') ? 0.5 : 0.9} strokeDasharray={k.includes('.upper') || k.includes('.lower') ? '3 3' : undefined} />)}
        {(chart.levels || []).map((l, j) => {
          const col = l.kind === 'sl' ? 'var(--bear)' : l.kind === 'tp' ? 'var(--bull)' : 'var(--gold)';
          return (<g key={'l' + j}><line x1={padL} x2={padL + innerW} y1={y(l.price)} y2={y(l.price)} stroke={col} strokeDasharray="5 3" strokeWidth="1" opacity="0.8" />
            <text x={padL + 3} y={y(l.price) - 3} fontSize="9" fill={col} fontFamily="var(--font-mono)" fontWeight="600">{l.label} {fmtPx(l.price)}</text></g>);
        })}
        {(chart.markers || []).filter(mk => mk.t >= t0 - (span * 0.02) && mk.t <= t1 + (span * 0.02)).map((mk, j) => {
          const mx = x(idxOf(mk.t));
          const col = mk.side === 'short' ? 'var(--bear)' : mk.side === 'long' ? 'var(--bull)' : 'var(--gold)';
          const short = mk.signal_id ? `#${mk.signal_id}${/closed/.test(mk.label) ? ' ✕' : mk.side === 'short' ? ' ▼' : mk.side === 'long' ? ' ▲' : ''}` : mk.label;
          return (<g key={'m' + j}><title>{mk.label}</title><line x1={mx} x2={mx} y1={padT} y2={H - 16} stroke={col} strokeDasharray="2 3" opacity="0.75" />
            <text x={mx > padL + innerW - 50 ? mx - 3 : mx + 3} textAnchor={mx > padL + innerW - 50 ? 'end' : 'start'} y={padT + 9 + (j % 5) * 11} fontSize="9" fill={col} fontFamily="var(--font-mono)" fontWeight="600">{short}</text></g>);
        })}
        {[0, 0.5, 1].map(f => { const i = Math.round(f * (cs.length - 1)); return <text key={'d' + f} x={Math.min(Math.max(x(i), padL + 18), padL + innerW - 18)} y={H - 3} fontSize="9.5" fill="var(--text-4)" textAnchor="middle" fontFamily="var(--font-mono)">{tick(cs[i][0])}</text>; })}
        {hover != null && <line x1={x(hover)} x2={x(hover)} y1={padT} y2={H + gap + PH} stroke="var(--text-3)" strokeWidth="0.6" />}
        {paneKey && (
          <g>
            <text x={padL + 2} y={H + gap + 9} fontSize="9" fill="var(--text-4)" fontFamily="var(--font-mono)">{paneKey.toUpperCase()}{hover != null && paneSeries[0] && paneSeries[0][1][hover] != null ? ' ' + Number(paneSeries[0][1][hover]).toFixed(1) : ''}</text>
            {paneKey.startsWith('rsi') && [30, 70].map(v => <line key={v} x1={padL} x2={padL + innerW} y1={py(v)} y2={py(v)} stroke="var(--border)" strokeDasharray="2 3" />)}
            {paneKey.startsWith('macd') && pane.hist && pane.hist.map((v, i) => v == null ? null : <rect key={i} x={x(i) - bw / 2} width={bw} y={Math.min(py(0), py(v))} height={Math.abs(py(v) - py(0))} fill={v >= 0 ? 'var(--bull)' : 'var(--bear)'} opacity="0.5" />)}
            {paneSeries.filter(([k]) => k !== 'hist').map(([k, a], j) => <path key={k} d={a.map((v, i) => (v == null ? '' : `${i && a[i - 1] != null ? 'L' : 'M'}${x(i).toFixed(1)} ${py(v).toFixed(1)}`)).join('')} fill="none" stroke={j ? 'var(--info)' : 'var(--violet)'} strokeWidth="1.2" />)}
          </g>
        )}
      </svg>
      {(chart.markers || []).some(mk => mk.signal_id && !/closed/.test(mk.label)) && (
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: '2px 12px', fontSize: 10.5, color: 'var(--text-3)', margin: '2px 4px 2px', fontFamily: 'var(--font-mono)' }}>
          {chart.markers.filter(mk => mk.signal_id && !/closed/.test(mk.label)).map((mk, j) => (
            <span key={j} style={{ color: mk.side === 'short' ? 'var(--bear)' : 'var(--bull)' }}>{mk.label}</span>
          ))}
          <span>✕ closed</span>
        </div>
      )}
    </div>
  );
}

// ── fix card ───────────────────────────────────────────────────────────────
function describeChange(f) {
  const p = f.params || {};
  if (f.action === 'close_signal') return `Close #${f.signal_id}` + (p.close_price != null ? ` at ${p.close_price}` : '') + (p.closed_at ? `, ${p.closed_at.replace('T', ' ').slice(0, 16)} UTC` : '');
  if (f.action === 'cancel_signal') return `Cancel #${f.signal_id} (removed from the open ledger)`;
  if (f.action === 'patch_levels') {
    const s = f.signal || {};
    const parts = [];
    if (p.side != null) parts.push(`side ${s.side} → ${p.side}`);
    if (p.entry_price != null) parts.push(`entry ${s.entry_price ?? '—'} → ${p.entry_price}`);
    if (p.sl != null) parts.push(`SL ${s.sl ?? '—'} → ${p.sl}`);
    if (p.tps != null) parts.push(`TPs ${(s.tps || []).join(', ') || '—'} → ${p.tps.join(', ')}`);
    return `#${f.signal_id}: ` + parts.join(' · ');
  }
  if (/^account_/.test(f.action)) {
    const where = f.account ? f.account.touches : 'this account';
    if (f.action === 'account_set_stop') return `Set the stop to ${p.stop} on ${where}`;
    if (f.action === 'account_set_targets') return `Set the target to ${p.tp} on ${where}`;
    if (f.action === 'account_close_position') return `Close this position at market on ${where}`;
  }
  if (f.action === 'relink_updates') {
    const n = (p.sub_event_ids || []).length;
    const what = `${n} update${n === 1 ? '' : 's'}`;
    const s = f.signal || {};
    if (p.new_trade) {
      const nt = p.new_trade;
      const when = p.from_message && p.from_message.at
        ? new Date(p.from_message.at).toUTCString().slice(5, 22) + ' UTC' : `message ${p.new_from_sub_event_id}`;
      const entry = nt.entry_price != null ? ` at ${nt.entry_price}` : ' at the market price then';
      const adds = (nt.add_sub_event_ids || []).length;
      return `Create a ${nt.symbol} ${nt.side} from the message of ${when}${entry}, and move ${what} from #${f.signal_id} onto it`
        + (adds ? ` (${adds} add-on message${adds === 1 ? '' : 's'} kept as history)` : '');
    }
    if (p.new_from_sub_event_id != null) return `Make setup message ${p.new_from_sub_event_id} its own ${s.symbol || ''} ${s.side || ''} trade, and move ${what} from #${f.signal_id} to it`;
    return `Move ${what} from #${f.signal_id} to #${p.to_signal_id}`;
  }
  return null;
}

/* What a relink would do to a trade that already exists: fields it would empty or change. A move
 * may change a trade's stop and state (that is the point); its coin, side and entry never. */
function relinkWarnings(before, after) {
  if (!before || !after) return [];
  const out = [];
  const fmt = v => (v == null ? 'empty' : typeof v === 'number' ? fmtPx(v) : String(v));
  for (const [k, name, hard] of [['symbol', 'coin', true], ['side', 'side', true], ['entry', 'entry', true], ['sl', 'stop', false]]) {
    const b = before[k], a = after[k];
    if (b === a || (b == null && a == null)) continue;
    if (a == null) out.push({ field: k, hard, text: `#${before.id} would lose its ${name} ${fmt(b)}` });
    else if (hard) out.push({ field: k, hard, text: `#${before.id}'s ${name} would change ${fmt(b)} → ${fmt(a)}` });
  }
  return out;
}

// One trade in a relink preview: state, stop, updates, levels. `warn` = fields to show in red.
function RelinkSide({ label, t, highlight, warn }) {
  if (!t) return null;
  const fmt = v => (v == null ? '—' : fmtPx(Number(v)));
  const bad = new Set((warn || []).map(w => w.field));
  const red = f => (bad.has(f) ? { color: 'var(--bear)', fontWeight: 700 } : undefined);
  return (
    <div style={{ flex: '1 1 150px', minWidth: 0, padding: '6px 8px', borderRadius: 7, background: highlight ? 'var(--gold-bg, var(--bg-elev))' : 'var(--bg-elev)' }}>
      <div style={{ fontSize: 10.5, color: 'var(--text-3)', marginBottom: 3 }}>{label}</div>
      <div style={{ fontWeight: 650, fontSize: 12 }}>#{t.id} <span style={red('symbol')}>{t.symbol}</span> <span style={red('side')}>{t.side}</span></div>
      <div className="mono" style={{ fontSize: 11, color: 'var(--text-2)', lineHeight: 1.55 }}>
        {t.state}{t.close_reason ? ` (${t.close_reason.replace(/_/g, ' ')})` : ''}<br />
        entry <span style={red('entry')}>{fmt(t.entry)}</span> · SL <span style={red('sl')}>{fmt(t.sl)}</span><br />
        TPs {(t.tps || []).length ? t.tps.map(fmt).join(', ') : '—'}<br />
        {(t.updates || []).length} message{(t.updates || []).length === 1 ? '' : 's'}
      </div>
    </div>
  );
}

function RelinkPreview({ data }) {
  const { before, after } = data;
  const wFrom = relinkWarnings(before.from, after.from);
  const wTo = relinkWarnings(before.to, after.to);
  const all = wFrom.concat(wTo);
  return (
    <div style={{ margin: '0 0 8px' }}>
      <div style={{ fontSize: 11, color: 'var(--text-3)', margin: '0 0 4px' }}>Before → after</div>
      {all.length > 0 && (
        <div className="as-err" style={{ margin: '0 0 6px' }}>
          {all.map((w, i) => <div key={i}>⚠ {w.text}</div>)}
          {all.some(w => w.hard) && <div style={{ fontWeight: 400, marginTop: 2 }}>Don't apply this; fix it by hand.</div>}
        </div>
      )}
      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'stretch' }}>
        <RelinkSide label="This trade now" t={before.from} />
        <span style={{ alignSelf: 'center', color: 'var(--text-4)' }}>→</span>
        <RelinkSide label="This trade after" t={after.from} highlight warn={wFrom} />
      </div>
      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'stretch', marginTop: 6 }}>
        {before.to ? <RelinkSide label="Other trade now" t={before.to} /> : <div style={{ flex: '1 1 150px', fontSize: 11, color: 'var(--text-4)', alignSelf: 'center' }}>No trade yet</div>}
        <span style={{ alignSelf: 'center', color: 'var(--text-4)' }}>→</span>
        <RelinkSide label={before.to ? 'Other trade after' : 'New trade'} t={after.to} highlight warn={wTo} />
      </div>
    </div>
  );
}

// "Apply all": the fixes of a review, in order, applied by the server after one confirm.
const PLAN_STEP_LABEL = { applied: '✓ applied', refused: '✗ refused', skipped: '– skipped', stopped: '■ stopped' };
function PlanCard({ plan: initial, onSignal, onChanged }) {
  const [plan, setPlan] = useStateAS(initial);
  const [confirm, setConfirm] = useStateAS(false);
  const [busy, setBusy] = useStateAS(false);
  const [err, setErr] = useStateAS(null);
  const [previews, setPreviews] = useStateAS(null);
  const steps = plan.steps || [];
  const trades = [...new Set(steps.map(s => s.fix && s.fix.signal_id).filter(Boolean))];
  const running = busy || plan.status === 'running';
  const refresh = useCallbackAS(async () => {
    const j = await fetch(`/api/assistant/plans/${plan.id}`, { credentials: 'include' }).then(r => r.json()).catch(() => null);
    if (j && j.ok) setPlan(p => ({ ...p, ...j.data }));
    return j && j.ok ? j.data : null;
  }, [plan.id]);
  useEffectAS(() => {
    if (!running) return undefined;
    const t = setInterval(refresh, 1500);
    return () => clearInterval(t);
  }, [running, refresh]);
  async function previewAll() {
    setBusy(true); setErr(null);
    const out = {};
    for (const s of steps) {
      if (!s.fix || s.fix.action !== 'relink_updates') continue;
      const j = await fetch(`/api/assistant/fixes/${s.fix_id}/preview`, { method: 'POST', credentials: 'include' }).then(r => r.json()).catch(e => ({ ok: false, error: String(e) }));
      out[s.fix_id] = j.ok ? 'would apply' : (j.error || 'refused');
    }
    setPreviews(out); setBusy(false);
  }
  async function applyAll() {
    setBusy(true); setErr(null); setConfirm(false);
    setPlan(p => ({ ...p, status: 'running' }));
    try {
      const r = await fetch(`/api/assistant/plans/${plan.id}/apply`, { method: 'POST', credentials: 'include' });
      const j = await r.json().catch(() => ({}));
      if (!r.ok || !j.ok) setErr(j.error || `Failed (HTTP ${r.status})`);
      else setPlan(p => ({ ...p, ...j.data }));
    } catch (e) { setErr(String(e.message || e)); }
    setBusy(false);
    await refresh();
    onChanged && onChanged();
  }
  async function stop() {
    await fetch(`/api/assistant/plans/${plan.id}/stop`, { method: 'POST', credentials: 'include' }).catch(() => {});
  }
  const applied = steps.filter(s => s.run && s.run.status === 'applied').length;
  return (
    <div className={'as-fix ' + (plan.status === 'done' && applied === steps.length ? 'applied' : '')}>
      <div className="as-fix-t">
        <span style={{ color: 'var(--gold)' }}>▤</span>
        <span style={{ flex: 1 }}>{plan.title}</span>
        <span className="chip neutral" style={{ fontSize: 10 }}>
          {plan.status === 'proposed' ? `${steps.length} fixes` : plan.status === 'running' ? 'Running…' : `${applied} of ${steps.length} applied`}
        </span>
      </div>
      <ol style={{ margin: '8px 0 8px', paddingLeft: 20, fontSize: 12, color: 'var(--text-2)', lineHeight: 1.5 }}>
        {steps.map(s => (
          <li key={s.fix_id} style={{ marginBottom: 3 }}>
            <span>{mdInline((s.fix && s.fix.title) || `fix #${s.fix_id}`, onSignal, 'p' + s.fix_id)}</span>
            {s.depends_on && s.depends_on.length > 0 && <span style={{ color: 'var(--text-4)', fontSize: 11 }}> · after {s.depends_on.map(d => steps.findIndex(x => x.fix_id === d) + 1).join(', ')}</span>}
            {s.run && <div style={{ fontSize: 11, color: s.run.status === 'applied' ? 'var(--bull)' : s.run.status === 'refused' ? 'var(--bear)' : 'var(--text-3)' }}>
              {PLAN_STEP_LABEL[s.run.status] || s.run.status}{s.run.reason ? ': ' + s.run.reason : ''}{s.run.created_signal_id ? ` (new trade #${s.run.created_signal_id})` : ''}</div>}
            {!s.run && previews && previews[s.fix_id] && <div style={{ fontSize: 11, color: previews[s.fix_id] === 'would apply' ? 'var(--text-3)' : 'var(--bear)' }}>preview: {previews[s.fix_id]}</div>}
          </li>
        ))}
      </ol>
      {previews && <div style={{ fontSize: 10.5, color: 'var(--text-4)', margin: '-2px 0 8px' }}>Previews check each move on its own, before the earlier steps run; a step that only works after an earlier one can show as refused here.</div>}
      {err && <div className="as-err" style={{ margin: '0 0 8px' }}>{err}</div>}
      <div className="as-fix-a">
        {plan.status === 'proposed' && plan.can_apply && !confirm && (
          <>
            <button className="as-btn" disabled={busy} onClick={previewAll}>{busy ? 'Checking…' : 'Preview all'}</button>
            <button className="as-btn primary" disabled={busy} onClick={() => setConfirm(true)}>Apply all ({steps.length} fixes)</button>
          </>
        )}
        {plan.status === 'proposed' && plan.can_apply && confirm && (
          <>
            <span style={{ fontSize: 11.5, color: 'var(--text-2)' }}>This changes {trades.length} trade{trades.length === 1 ? '' : 's'} in this order. You can stop it at any step.</span>
            <button className="as-btn primary go" onClick={applyAll}>Yes, apply all</button>
            <button className="as-btn" onClick={() => setConfirm(false)}>No</button>
          </>
        )}
        {running && <button className="as-btn" onClick={stop}>Stop</button>}
        {plan.status === 'proposed' && !plan.can_apply && <span style={{ fontSize: 10.5, color: 'var(--text-4)' }}>Only the admin can apply ledger fixes.</span>}
      </div>
    </div>
  );
}

function FixCard({ fix, onSignal, onChanged }) {
  const [status, setStatus] = useStateAS(fix.status || 'proposed');
  const [confirm, setConfirm] = useStateAS(false);
  const [busy, setBusy] = useStateAS(false);
  const [err, setErr] = useStateAS(null);
  const [preview, setPreview] = useStateAS(null);
  const change = describeChange(fix);
  const isRelink = fix.action === 'relink_updates';
  // A relink rebuilds two trades, so the admin sees exactly what it does before confirming.
  async function startApply() {
    if (!isRelink) { setConfirm(true); return; }
    setBusy(true); setErr(null);
    try {
      const r = await fetch(`/api/assistant/fixes/${fix.id}/preview`, { method: 'POST', credentials: 'include' });
      const j = await r.json().catch(() => ({}));
      if (!r.ok || !j.ok) { setErr(j.error || `Preview failed (HTTP ${r.status})`); return; }
      setPreview(j.data);
      setConfirm(true);
    } catch (e) { setErr(String(e.message || e)); } finally { setBusy(false); }
  }
  async function act(path, body) {
    setBusy(true); setErr(null);
    try {
      const r = await fetch(`/api/assistant/fixes/${fix.id}/${path}`, { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) });
      const j = await r.json().catch(() => ({}));
      if (!r.ok || !j.ok) { setErr(j.error || `Failed (HTTP ${r.status})`); return; }
      setStatus(path === 'apply' ? 'applied' : 'dismissed');
      setConfirm(false);
      setPreview(null);
      onChanged && onChanged();
    } catch (e) { setErr(String(e.message || e)); } finally { setBusy(false); }
  }
  const label = { applied: '✓ Applied', dismissed: 'Dismissed', failed: 'Failed', applying: 'Applying…' }[status];
  return (
    <div className={'as-fix ' + status}>
      <div className="as-fix-t">
        <span style={{ color: status === 'applied' ? 'var(--bull)' : 'var(--gold)' }}>{status === 'applied' ? '✓' : '●'}</span>
        <span style={{ flex: 1 }}>{mdInline(fix.title || 'Suggested fix', onSignal, 't')}</span>
        {label && <span className="chip neutral" style={{ fontSize: 10 }}>{label}</span>}
      </div>
      <div className="as-fix-x">{mdInline(fix.explanation || '', onSignal, 'x')}</div>
      {(fix.evidence || []).length > 0 && <ul className="as-fix-ev">{fix.evidence.map((e, i) => <li key={i}>{mdInline(e, onSignal, 'e' + i)}</li>)}</ul>}
      {change && <div className="as-change">{change}</div>}
      {preview && confirm && <RelinkPreview data={preview} />}
      {err && <div className="as-err" style={{ margin: '0 0 8px' }}>{err}</div>}
      <div className="as-fix-a">
        {fix.signal_id != null && <button className="as-btn" onClick={() => onSignal(fix.signal_id)}>Open #{fix.signal_id}</button>}
        {status === 'proposed' && fix.can_apply && !confirm && <button className="as-btn primary" disabled={busy} onClick={startApply}>{busy && isRelink ? 'Checking…' : isRelink ? 'Preview fix' : 'Apply fix'}</button>}
        {status === 'proposed' && fix.can_apply && confirm && (
          <>
            <span style={{ fontSize: 11.5, color: /^account_/.test(fix.action) ? 'var(--gold)' : 'var(--text-2)', fontWeight: /^account_/.test(fix.action) ? 650 : 400 }}>
              {/^account_/.test(fix.action)
                ? `This changes your ${fix.action === 'account_close_position' ? 'position' : fix.action === 'account_set_stop' ? 'stop' : 'target'} on ${(fix.account && fix.account.touches) || 'your exchange account'}. Go ahead?`
                : 'Change the ledger now?'}
            </span>
            <button className="as-btn primary go" disabled={busy} onClick={() => act('apply')}>{busy ? 'Applying…' : 'Yes, apply'}</button>
            <button className="as-btn" disabled={busy} onClick={() => { setConfirm(false); setPreview(null); }}>No</button>
          </>
        )}
        {status === 'proposed' && !confirm && (
          <button className="as-btn" disabled={busy} title={fix.can_apply ? 'Not a problem: hide it from later reviews' : 'Hide this suggestion'}
                  onClick={() => act('dismiss', { ignore_finding: true })}>{fix.can_apply ? 'Not a problem' : 'Dismiss'}</button>
        )}
        {status === 'proposed' && !fix.can_apply && fix.action !== 'manual' && <span style={{ fontSize: 10.5, color: 'var(--text-4)' }}>{/^account_/.test(fix.action) ? 'Only the account owner can apply this.' : 'Only the admin can apply ledger fixes.'}</span>}
        {fix.action === 'manual' && status === 'proposed' && <span style={{ fontSize: 10.5, color: 'var(--text-4)' }}>No automatic edit for this one; follow the steps.</span>}
      </div>
    </div>
  );
}

// ── stream reader ──────────────────────────────────────────────────────────
async function readEvents(response, onEvent, signal) {
  const reader = response.body.getReader();
  const dec = new TextDecoder();
  let buf = '';
  while (true) {
    if (signal && signal.aborted) { reader.cancel().catch(() => {}); return; }
    const { value, done } = await reader.read();
    if (done) break;
    buf += dec.decode(value, { stream: true });
    let idx;
    while ((idx = buf.indexOf('\n\n')) >= 0) {
      const chunk = buf.slice(0, idx); buf = buf.slice(idx + 2);
      let ev = 'message', data = '';
      for (const ln of chunk.split('\n')) {
        if (ln.startsWith('event: ')) ev = ln.slice(7);
        else if (ln.startsWith('data: ')) data += ln.slice(6);
      }
      if (data) { try { onEvent(ev, JSON.parse(data)); } catch (_) { /* partial */ } }
    }
  }
}

const AS_STARTERS = {
  chat: [
    'Which trader called the last BTC move best?',
    'Which strategy has done best on ETH this month, and why?',
    'What was RSI on SOL when the most recent SOL long was called?',
    'Show me BTC over the last 2 weeks with EMA 20/50 and RSI.',
  ],
  account: label => [
    `Is my ${label} account in sync with the traders I follow?`,
    'Are all my open positions protected by a stop?',
    'Did the traders take profit or close anything I still hold?',
    'Why were setups skipped on this account this week?',
  ],
  strategy: name => [
    `Is anything wrong in ${name}'s ledger?`,
    `What are ${name}'s best and worst coins this month?`,
    `How early does ${name} usually call moves?`,
    `Which of ${name}'s open trades are closest to their stop?`,
  ],
};

// Normal (Sonnet) or Expert (Opus), per conversation. Reviews default to Expert (fix cards,
// ledger edits); plain questions to Normal. The last choice per kind is remembered.
const AS_MODES = {
  normal: { label: 'Normal · Sonnet', hint: 'Faster and about half the price. Good for questions.' },
  expert: { label: 'Expert · Opus', hint: 'The most careful. Best for ledger, history and account reviews.' },
};
const AS_LONG_CHAT_TOKENS = 80000;
const modeKey = kind => 'assistant-mode-' + (/^review/.test(kind || '') ? 'review' : 'chat');
function preferredMode(kind) {
  try { const v = localStorage.getItem(modeKey(kind)); if (AS_MODES[v]) return v; } catch (_) { /* private mode */ }
  return /^review/.test(kind || '') ? 'expert' : 'normal';
}

// ── the drawer ─────────────────────────────────────────────────────────────
function W_Assistant({ user, onNavigate }) {
  const allowed = user && (user.role === 'admin' || user.role === 'arena');
  const [open, setOpen] = useStateAS(false);
  const [page, setPage] = useStateAS({ kind: 'chat' });
  const [convId, setConvId] = useStateAS(null);
  const [turns, setTurns] = useStateAS([]);
  const [input, setInput] = useStateAS('');
  const [busy, setBusy] = useStateAS(false);
  const [usage, setUsage] = useStateAS(null);
  const [history, setHistory] = useStateAS(null);
  const [wide, setWide] = useStateAS(false);
  const [timeline, setTimeline] = useStateAS(null);
  const [mode, setMode] = useStateAS('normal');
  const [contextTokens, setContextTokens] = useStateAS(0);
  const [planDone, setPlanDone] = useStateAS(false);
  const body = useRefAS(null);
  const abortRef = useRefAS(null);
  const pendingSend = useRefAS(null);
  const taRef = useRefAS(null);
  const timelineOpenRef = useRefAS(false);

  const refreshUsage = useCallbackAS(() => {
    fetch('/api/assistant/usage', { credentials: 'include' }).then(r => r.json()).then(j => { if (j.ok) setUsage(j.data); }).catch(() => {});
  }, []);

  useEffectAS(() => {
    if (!allowed) return undefined;
    const onOpen = e => {
      const d = (e && e.detail) || {};
      setOpen(true);
      setHistory(null);
      if (d.strategy_id || d.signal_id || d.account_id || /^review/.test(d.kind || '') || d.fresh) {
        abortRef.current && abortRef.current.abort();
        setConvId(null); setTurns([]); setBusy(false); setContextTokens(0); setPlanDone(false);
        setMode(preferredMode(d.kind || 'chat'));
        setPage({ kind: d.kind || 'chat', strategy_id: d.strategy_id || null, signal_id: d.signal_id || null, strategy_name: d.strategy_name || null,
                  account_id: d.account_id || null, account_label: d.account_label || null });
      }
      if (d.prompt) { if (d.autoSend) pendingSend.current = d.prompt; else setInput(d.prompt); }
      refreshUsage();
      setTimeout(() => taRef.current && taRef.current.focus(), 280);
    };
    const onKey = e => {
      if (e.key === '/' && !open && !/INPUT|TEXTAREA|SELECT/.test((document.activeElement || {}).tagName || '') && !e.metaKey && !e.ctrlKey) { e.preventDefault(); onOpen({}); }
      if (e.key === 'Escape' && open) { if (timelineOpenRef.current) setTimeline(null); else setOpen(false); }
    };
    window.addEventListener('open-assistant', onOpen);
    window.addEventListener('keydown', onKey);
    return () => { window.removeEventListener('open-assistant', onOpen); window.removeEventListener('keydown', onKey); };
  }, [allowed, open, refreshUsage]);

  useEffectAS(() => {
    if (pendingSend.current && !busy && open) { const p = pendingSend.current; pendingSend.current = null; send(p); }
  });

  useEffectAS(() => {
    if (body.current) body.current.scrollTop = body.current.scrollHeight;
  }, [turns]);
  useEffectAS(() => { timelineOpenRef.current = !!timeline; }, [timeline]);

  const updateLast = fn => setTurns(ts => { if (!ts.length) return ts; const c = ts.slice(); c[c.length - 1] = fn({ ...c[c.length - 1], parts: c[c.length - 1].parts.slice() }); return c; });

  async function send(textArg) {
    const text = String(textArg != null ? textArg : input).trim();
    if (!text || busy) return;
    setInput('');
    setBusy(true);
    setTurns(ts => ts.concat([{ question: text, parts: [], live: true }]));
    const ac = new AbortController();
    abortRef.current = ac;
    try {
      const r = await fetch('/api/assistant/chat', {
        method: 'POST', credentials: 'include', signal: ac.signal,
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ message: text, conversation_id: convId, page, mode }),
      });
      if (!r.ok || !(r.headers.get('content-type') || '').includes('text/event-stream')) {
        const j = await r.json().catch(() => ({}));
        updateLast(t => ({ ...t, live: false, parts: t.parts.concat([{ type: 'error', text: j.error || `Request failed (HTTP ${r.status})` }]) }));
        return;
      }
      await readEvents(r, (ev, d) => {
        if (ev === 'start') { setConvId(d.conversation_id); return; }
        updateLast(t => {
          const parts = t.parts;
          const lastPart = parts[parts.length - 1];
          if (ev === 'text') {
            if (lastPart && lastPart.type === 'text') parts[parts.length - 1] = { ...lastPart, text: lastPart.text + d.delta };
            else parts.push({ type: 'text', text: d.delta });
          } else if (ev === 'tool_start') parts.push({ type: 'step', id: d.id, label: d.label, running: true });
          else if (ev === 'tool_end') { const i = parts.findIndex(p => p.type === 'step' && p.id === d.id); if (i >= 0) parts[i] = { ...parts[i], running: false, ok: d.ok }; }
          else if (ev === 'artifact') parts.push(d);
          else if (ev === 'notice') parts.push({ type: 'notice', text: d.text });
          else if (ev === 'error') parts.push({ type: 'error', text: d.error });
          else if (ev === 'usage') { if (d.context_tokens) setContextTokens(d.context_tokens); return { ...t, cost: d.cost_usd }; }
          else if (ev === 'done') { setUsage(u => (u ? { ...u, spent_usd: d.spent_today_usd, limit_usd: d.limit_usd } : u)); return { ...t, live: false }; }
          return { ...t, parts };
        });
      }, ac.signal);
    } catch (e) {
      if (!ac.signal.aborted) updateLast(t => ({ ...t, parts: t.parts.concat([{ type: 'error', text: String(e.message || e) }]) }));
    } finally {
      updateLast(t => ({ ...t, live: false, parts: t.parts.map(p => (p.type === 'step' && p.running ? { ...p, running: false, ok: false } : p)) }));
      setBusy(false);
      abortRef.current = null;
      refreshUsage();
    }
  }

  function stop() { abortRef.current && abortRef.current.abort(); }
  function newChat() { stop(); setConvId(null); setTurns([]); setPage({ kind: 'chat' }); setHistory(null); setContextTokens(0); setPlanDone(false); setMode(preferredMode('chat')); }
  // A fresh chat on the same page (same trader / account / review), for the next question.
  function freshSamePage() { stop(); setConvId(null); setTurns([]); setHistory(null); setContextTokens(0); setPlanDone(false); }

  // Changing mode on a chat that has messages starts a new chat (the model is per conversation);
  // the old one stays in History, and its last answer is carried into the new one.
  async function changeMode(next) {
    if (!AS_MODES[next] || next === mode) return;
    try { localStorage.setItem(modeKey(page.kind), next); } catch (_) { /* private mode */ }
    if (!convId || !turns.length) { setMode(next); return; }
    stop();
    const r = await fetch(`/api/assistant/conversations/${convId}/switch-mode`, {
      method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: next }),
    }).then(x => x.json()).catch(() => ({}));
    if (!r.ok) return;
    setMode(next);
    setConvId(r.data.conversation.id);
    setTurns([]); setContextTokens(0); setPlanDone(false);
    setPage(p => ({ ...p, ...(r.data.conversation.scope || {}) }));
  }

  async function showHistory() {
    if (history) { setHistory(null); return; }
    const j = await fetch('/api/assistant/conversations', { credentials: 'include' }).then(r => r.json()).catch(() => ({}));
    setHistory(j.ok ? j.data : []);
  }
  async function loadConversation(id) {
    stop();
    const j = await fetch('/api/assistant/conversations/' + id, { credentials: 'include' }).then(r => r.json()).catch(() => ({}));
    if (!j.ok) return;
    setConvId(id);
    setPage(j.data.conversation.scope || { kind: 'chat' });
    setMode(j.data.mode || 'expert');
    setContextTokens(j.data.context_tokens || 0);
    setPlanDone(false);
    setTurns(j.data.turns.map(t => ({ ...t, parts: t.parts.map(p => (p.type === 'step' ? { ...p, running: false } : p)) })));
    setHistory(null);
  }
  async function deleteConversation(id, e) {
    e.stopPropagation();
    await fetch('/api/assistant/conversations/' + id, { method: 'DELETE', credentials: 'include' });
    setHistory(h => (h || []).filter(c => c.id !== id));
    if (convId === id) newChat();
  }

  function openSignal(id) {
    setTimeline({ loading: true });
    fetch(`/api/signals/${id}/timeline`, { credentials: 'include' }).then(r => r.json()).then(j => {
      if (j.ok) setTimeline({ signal: j.data.signal, posts: j.data.posts });
      else setTimeline({ error: j.error || 'Could not load this trade' });
    }).catch(e => setTimeline({ error: String(e.message || e) }));
  }

  if (!allowed) return null;
  const scopeLabel = page.account_id ? `${page.account_label || 'Account'}${page.kind === 'review_account' ? ' · account review' : ''}`
    : page.signal_id ? `Trade #${page.signal_id}`
    : page.strategy_name ? (page.kind === 'review' ? `${page.strategy_name} · ledger review` : page.kind === 'review_history' ? `${page.strategy_name} · history review` : page.strategy_name) : null;
  const starters = page.account_id ? AS_STARTERS.account(page.account_label || 'this')
    : page.strategy_name ? AS_STARTERS.strategy(page.strategy_name) : AS_STARTERS.chat;
  const pct = usage && usage.limit_usd ? Math.min(100, (usage.spent_usd / usage.limit_usd) * 100) : 0;

  return (
    <>
      <style>{AS_CSS}</style>
      <div className={'as-drawer' + (open ? ' open' : '') + (wide ? ' wide' : '')} role="dialog" aria-label="Assistant" aria-hidden={!open}>
        <div className="as-head">
          <div className="as-title"><span className="as-spark" style={{ width: 22, height: 22, fontSize: 12, borderRadius: 6 }}>✦</span>Assistant</div>
          {scopeLabel && <span className="as-scope" title={scopeLabel}>{scopeLabel}</span>}
          <select className="as-mode" value={mode} onChange={e => changeMode(e.target.value)} disabled={busy}
                  title={AS_MODES[mode].hint + (turns.length ? ' Switching mode starts a new chat (this one stays in History).' : '')}
                  aria-label="Assistant mode">
            {Object.entries(AS_MODES).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
          </select>
          <span style={{ marginLeft: 'auto', display: 'flex', gap: 2 }}>
            <button className="as-iconbtn" onClick={newChat} title="New chat">＋ New</button>
            <button className="as-iconbtn" onClick={showHistory} title="Past conversations">{history ? 'Back' : 'History'}</button>
            <button className="as-iconbtn desktop-only" onClick={() => setWide(v => !v)} title={wide ? 'Narrower' : 'Wider'}>{wide ? '⇥' : '⇤'}</button>
            <button className="as-iconbtn" onClick={() => setOpen(false)} title="Close (Esc)" aria-label="Close">✕</button>
          </span>
        </div>
        <div style={{ position: 'relative', flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
          {history && (
            <div className="as-hist">
              {!history.length && <div style={{ color: 'var(--text-4)', fontSize: 12, padding: 10 }}>No conversations yet.</div>}
              {history.map(c => (
                <div key={c.id} className="as-hrow" onClick={() => loadConversation(c.id)}>
                  <span style={{ color: (c.scope || {}).kind === 'review' ? 'var(--gold)' : 'var(--text-4)' }}>{(c.scope || {}).kind === 'review' ? '◆' : '◇'}</span>
                  <span className="t">{c.title || 'Untitled'}</span>
                  <span className="d">{new Date(c.updated_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</span>
                  <button className="as-iconbtn" onClick={e => deleteConversation(c.id, e)} title="Delete">🗑</button>
                </div>
              ))}
            </div>
          )}
          <div className="as-body" ref={body}>
            {!turns.length && (
              <div className="as-hero">
                <div className="as-spark">✦</div>
                <h2>{page.kind === 'review' ? `Review ${page.strategy_name || 'this'} ledger` : page.strategy_name ? `Ask about ${page.strategy_name}` : 'Ask about the traders and the market'}</h2>
                <p>It reads the ledger, the traders' messages and real prices, then shows its working. Trade numbers like #812 open the trade.
                  {user.role === 'admin' ? ' Suggested ledger fixes appear as cards you can apply.' : ' Suggested ledger fixes are shown as guidance.'}</p>
                <div className="as-starters">
                  {starters.map(s => <button key={s} className="as-starter" onClick={() => send(s)}>{s}</button>)}
                </div>
              </div>
            )}
            {turns.map((t, ti) => {
              const steps = [];
              const nodes = [];
              const flushSteps = key => { if (steps.length) { nodes.push(<div key={'s' + key} className="as-steps">{steps.splice(0).map(s => <span key={s.id} className={'as-step' + (s.running ? '' : s.ok === false ? ' fail' : ' done')}><span className="dot" />{s.label}</span>)}</div>); } };
              t.parts.forEach((p, pi) => {
                if (p.type === 'step') { steps.push(p); return; }
                flushSteps(pi);
                if (p.type === 'text') nodes.push(<Markdown key={pi} text={p.text} onSignal={openSignal} />);
                else if (p.type === 'chart') nodes.push(<AssistantChart key={pi} chart={p} />);
                else if (p.type === 'fix') nodes.push(<FixCard key={'f' + p.id} fix={p} onSignal={openSignal} onChanged={() => window.dispatchEvent(new CustomEvent('ledger-changed', { detail: { signal_id: p.signal_id } }))} />);
                else if (p.type === 'plan') nodes.push(<PlanCard key={'pl' + p.id} plan={p} onSignal={openSignal} onChanged={() => { setPlanDone(true); window.dispatchEvent(new CustomEvent('ledger-changed', { detail: {} })); }} />);
                else if (p.type === 'notice') nodes.push(<div key={pi} className="as-notice">{p.text}</div>);
                else if (p.type === 'error') nodes.push(<div key={pi} className="as-err">{p.text}</div>);
              });
              flushSteps('end');
              return (
                <div key={ti}>
                  <div className="as-q">{t.question}</div>
                  {nodes}
                  {t.live && !t.parts.some(p => p.type === 'text') && !t.parts.some(p => p.type === 'step') && <div className="as-steps"><span className="as-step"><span className="dot" />Thinking</span></div>}
                  {t.live && t.parts.length > 0 && t.parts[t.parts.length - 1].type === 'text' && <span className="as-caret" />}
                </div>
              );
            })}
          </div>
          {turns.length > 0 && !busy && (planDone || contextTokens > AS_LONG_CHAT_TOKENS) && (
            <div className="as-long" role="status">
              <span>This chat is long, so each question costs more. Start a fresh one for the next review.</span>
              <button className="as-btn" onClick={freshSamePage}>New chat here</button>
            </div>
          )}
          <div className="as-foot">
            <div className="as-input">
              <textarea ref={taRef} rows={1} value={input} placeholder={busy ? 'Working…' : turns.length ? 'Ask a follow-up…' : 'Ask anything about the traders, a coin, or this ledger…'}
                onChange={e => { setInput(e.target.value); e.target.style.height = 'auto'; e.target.style.height = Math.min(140, e.target.scrollHeight) + 'px'; }}
                onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }} />
              {busy ? <button className="as-send" onClick={stop} style={{ background: 'var(--bg-elev)', color: 'var(--text-2)' }}>Stop</button>
                : <button className="as-send" disabled={!input.trim()} onClick={() => send()}>Ask ➤</button>}
            </div>
            {usage && (
              <div>
                <div className="as-meta">
                  <span>Today: ${usage.spent_usd.toFixed(2)} of ${usage.limit_usd.toFixed(2)}{user.role === 'arena' ? ' (£0.50)' : ''}</span>
                  <span>Enter to send · Shift+Enter new line</span>
                </div>
                <div className="as-meter"><i style={{ width: pct + '%', background: pct > 85 ? 'var(--bear)' : undefined }} /></div>
              </div>
            )}
          </div>
        </div>
      </div>
      {timeline && typeof TimelineModal === 'function' && (
        <div className="as-over">
          <TimelineModal state={timeline} onClose={() => setTimeline(null)} />
        </div>
      )}
    </>
  );
}

// Top-bar button (admin + members).
function AssistantButton({ user }) {
  if (!user || (user.role !== 'admin' && user.role !== 'arena')) return null;
  return (
    <button className="as-ask-btn" onClick={() => openAssistant({})} title="Ask the assistant (press /)">
      <span>✦</span><span className="desktop-only">Ask</span>
    </button>
  );
}

Object.assign(window, { W_Assistant, AssistantButton, openAssistant, relinkWarnings });
