/* Signal pipeline page — replaces the old AI Signals section.
 *
 * Read-only over the worker's tables; the worker owns everything else. (The pipeline-wide
 * `control` settings are edited from My account, admin only.)
 *
 * Colour note: the allocation breakdown is a *categorical* encoding, so its hues
 * come from a set validated with the dataviz palette checker against this card
 * surface (#151a1f) — lightness band, chroma floor, CVD separation, normal-vision
 * floor and contrast all pass. The app's own --gold/--info/--violet are lovely as
 * single accents but fail CVD separation as a categorical set (violet↔info ΔE 3.2
 * deutan), so they are NOT used to distinguish series. --bull/--bear stay reserved
 * for PnL sign and are never a category.
 */

const { useState: useS, useEffect: useE, useCallback: useC, useRef: useR } = React;

const CAT_COLORS = ['#2f8adc', '#b98a00', '#c547a3'];
const CAT_OTHER = 'var(--text-4)';

const ACTION_TONE = {
  would_enter: 'bull', enter: 'bull', would_manage: 'info', manage: 'info',
  ledger_only: 'gold', alert_user: 'gold', needs_approval: 'gold',
  skipped: 'muted', none: 'muted',
};

// Friendly labels for gate reason codes (audit + feed). Raw code stays in title.
const REASON_LABEL = {
  NO_STOP_LOSS: 'No stop-loss',
  NO_SYMBOL: 'No symbol',
  NO_LINKED_SIGNAL: 'No linked signal',
  SYMBOL_NOT_TRADABLE: 'Symbol not tradable',
  SIDE_NOT_EXPLICIT: 'Side not explicit',
  SL_WRONG_SIDE_OF_ENTRY: 'Stop on wrong side of entry',
  MAGNITUDE_IMPLAUSIBLE: 'Price magnitude implausible',
  NUMBER_WITHOUT_SOURCE: 'Number not in text',
  FORWARDED_EXTERNAL: 'Forwarded message',
  DUPLICATE_MESSAGE_VERSION: 'Duplicate message',
  KILL_SWITCH_ON: 'Kill switch on',
  STRATEGY_INACTIVE: 'Strategy paused',
  DAILY_HALT: 'Daily halt',
  DAILY_LOSS_CAP_HIT: 'Daily loss cap hit',
  MAX_CONCURRENT_REACHED: 'Max open positions',
  BEFORE_LIVE_CUTOFF: 'Before live cutoff',
  SHADOW_MODE_NO_ORDER: 'Paper — no order',
  LIVE_ARMED: 'Live orders armed',
  SOURCE_OK: 'Source ok',
  SIGNAL_OK: 'Signal ok',
  RISK_OK: 'Risk ok',
  STOP_LOSS_PRESENT: 'Stop-loss present',
  SYMBOL_TRADABLE: 'Symbol tradable',
  SL_TOUCHED_SINCE_POST: 'Stop already hit',
  TP1_REACHED_SINCE_POST: 'TP1 already reached',
  DRIFT_EXCEEDS_TOLERANCE: 'Price drifted too far',
  RESTING_LIMIT_ENTRY: 'Resting limit at his entry',
  PULLED_BACK_BEYOND_TOLERANCE: 'Pulled back too far',
  REMAINING_RR_BELOW_FLOOR: 'Remaining R:R too low',
  AGE_BACKSTOP: 'Too old',
  PRICE_PATH_UNAVAILABLE: 'Price path unavailable',
  STOP_NOT_ON_LOSING_SIDE_OF_REFERENCE: 'Stop not on losing side',
};
function reasonLabel(code) {
  if (!code) return '—';
  if (REASON_LABEL[code]) return REASON_LABEL[code];
  if (code.startsWith('ADVISORY_')) {
    const inner = code.slice('ADVISORY_'.length);
    return 'Would skip live · ' + (REASON_LABEL[inner] || inner.replace(/_/g, ' ').toLowerCase());
  }
  if (code.startsWith('ILLEGAL_TRANSITION_')) return 'Illegal transition';
  if (code.startsWith('NO_OP_')) return 'No-op';
  if (code.startsWith('LINKED_')) return 'Linked signal';
  if (code.startsWith('TRANSITION_OK_')) return 'Transition ok';
  return code.replace(/_/g, ' ').toLowerCase();
}

// Scope a pipeline API path to one trader (strategy); no id = all of them.
function sigWithStrategy(path, strategyId) {
  if (!strategyId) return path;
  return path + (path.includes('?') ? '&' : '?') + 'strategy_id=' + encodeURIComponent(strategyId);
}

// The open ledger only cares about an explicit custom ?until (a chip like 1W has no
// natural end date and is a no-op there — see the doc comment on GET
// /api/signals/ledger) — no ?period is sent for chips, only for the History tab.
function openLedgerUrl(strategyId, period, customSince, customUntil, includeOrphans) {
  const qs = new URLSearchParams();
  if (period === 'custom' && customUntil) qs.set('until', customUntil);
  if (includeOrphans) qs.set('include_orphans', '1');
  const path = '/api/signals/ledger' + (qs.toString() ? '?' + qs.toString() : '');
  return sigWithStrategy(path, strategyId);
}

function meStrategyUrl(strategyId, path) {
  return `/api/me/strategies/${strategyId}/${path}`;
}

function openLedgerUrlUser(strategyId, period, customSince, customUntil, includeOrphans) {
  const qs = new URLSearchParams();
  if (period === 'custom' && customUntil) qs.set('until', customUntil);
  if (includeOrphans) qs.set('include_orphans', '1');
  return meStrategyUrl(strategyId, 'ledger') + (qs.toString() ? '?' + qs.toString() : '');
}

function fmtUsd(v, dp = 2) {
  if (v === null || v === undefined || v === '') return '—';
  const n = Number(v);
  if (!Number.isFinite(n)) return '—';
  return '$' + n.toLocaleString(undefined, { minimumFractionDigits: dp, maximumFractionDigits: dp });
}
function fmtNum(v) {
  // Number(null) is 0 and Number.isFinite(0) is true, so an explicit null check
  // has to come first — otherwise a signal with no stop renders as "0".
  if (v === null || v === undefined || v === '') return '—';
  const n = Number(v);
  if (!Number.isFinite(n)) return '—';
  if (Math.abs(n) >= 1000) return n.toLocaleString(undefined, { maximumFractionDigits: 2 });
  if (Math.abs(n) >= 1) return n.toLocaleString(undefined, { maximumFractionDigits: 4 });
  return n.toLocaleString(undefined, { maximumFractionDigits: 8 });
}
function agoFrom(iso) {
  if (!iso) return '—';
  const t = Date.parse(iso.endsWith('Z') ? iso : iso + 'Z');
  if (!Number.isFinite(t)) return '—';
  const s = Math.max(0, (Date.now() - t) / 1000);
  if (s < 90) return Math.round(s) + 's';
  if (s < 5400) return Math.round(s / 60) + 'm';
  if (s < 172800) return Math.round(s / 3600) + 'h';
  return Math.round(s / 86400) + 'd';
}
function isStale(iso, seconds) {
  if (!iso) return true;
  const t = Date.parse(iso.endsWith('Z') ? iso : iso + 'Z');
  return !Number.isFinite(t) || (Date.now() - t) / 1000 > seconds;
}

// Turns one signal_events.field_changes blob into short, readable lines — the
// same vocabulary apply_to_ledger itself writes (sl: [old,new], sl_rejected,
// close_pct, ...), translated for a row-expander rather than left as raw JSON.
function describeFieldChanges(changes) {
  if (!changes || typeof changes !== 'object') return [];
  const lines = [];
  const v = (x) => (x == null ? '—' : (typeof x === 'number' ? fmtNum(x) : String(x)));
  if (changes.sl) lines.push(`SL moved ${v(changes.sl[0])} → ${v(changes.sl[1])}`);
  if (changes.sl_rejected) {
    const r = changes.sl_rejected;
    lines.push(`Restated SL ${v(r.stated)} rejected — would have loosened the stop, kept ${v(r.kept)}`);
  }
  if (changes.sl_qualifier) lines.push(`SL qualifier: ${v(changes.sl_qualifier[0])} → ${v(changes.sl_qualifier[1])}`);
  if (changes.sl_derive) lines.push(`Breakeven move noted — no safe level to set yet`);
  if (changes.tps) lines.push('Targets updated');
  if (changes.reference_price) lines.push(`Entry reference ${v(changes.reference_price[0])} → ${v(changes.reference_price[1])}`);
  if (changes.close_pct != null) lines.push(`Partial close booked (~${changes.close_pct}%)`);
  if (changes.new_sl != null && !changes.sl && !changes.sl_rejected) lines.push(`Stated new SL: ${v(changes.new_sl)}`);
  const known = new Set(['sl', 'sl_rejected', 'sl_qualifier', 'sl_derive', 'tps', 'reference_price', 'close_pct', 'new_sl', 'state']);
  Object.keys(changes).forEach(k => {
    if (known.has(k)) return;
    const val = changes[k];
    lines.push(Array.isArray(val) && val.length === 2 ? `${k}: ${v(val[0])} → ${v(val[1])}` : `${k}: ${v(val)}`);
  });
  return lines;
}

// Inline row-expander: the mechanical side of a signal's history (SL moves,
// partial closes, a rejected revision) next to the message that caused each
// one — lazy-fetched once per row, cached for the life of the component.
function SignalHistorySubrow({ signalId, colSpan, url, onViewMessage }) {
  const [state, setState] = useS({ loading: true });
  useE(() => {
    let alive = true;
    fetch(url || ('/api/signals/' + signalId + '/timeline')).then(r => r.json()).then(j => {
      if (!alive) return;
      if (j.ok) setState({ loading: false, events: j.data.events || [] });
      else setState({ loading: false, error: j.error || 'Could not load history' });
    }).catch(e => { if (alive) setState({ loading: false, error: String(e.message || e) }); });
    return () => { alive = false; };
  }, [signalId, url]);

  return (
    <tr>
      <td colSpan={colSpan} style={{ padding: '10px 14px 14px 34px', background: 'var(--bg-elev)', borderTop: '1px solid var(--border-soft)' }}>
        {state.loading && <div style={{ fontSize: 11, color: 'var(--text-4)' }}>Loading history…</div>}
        {state.error && <div style={{ fontSize: 11, color: 'var(--bear)' }}>{state.error}</div>}
        {!state.loading && !state.error && (
          state.events.length ? (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
              {state.events.map(e => {
                const lines = describeFieldChanges(e.field_changes);
                const stateChanged = e.to_state && e.from_state !== e.to_state;
                return (
                  <div key={e.id} style={{ fontSize: 11, display: 'flex', gap: 10 }}>
                    <span className="mono" title={fmtStamp(e.at)} style={{ color: 'var(--text-4)', minWidth: 90, flexShrink: 0, paddingTop: 1, cursor: 'default' }}>
                      {agoFrom(e.at)} ago
                    </span>
                    {e.market_price_at_post != null && (
                      <span className="mono" title="The coin's price at the moment this message reached us"
                        style={{ color: 'var(--text-4)', minWidth: 72, flexShrink: 0, paddingTop: 1 }}>
                        @ {fmtNum(e.market_price_at_post)}
                      </span>
                    )}
                    <div style={{ flex: 1, minWidth: 0 }}>
                      {stateChanged && (
                        <span style={{ color: 'var(--text-3)', marginRight: 8 }}>
                          {POS_STATE_LABEL[e.from_state] || e.from_state || '—'} → {POS_STATE_LABEL[e.to_state] || e.to_state}
                        </span>
                      )}
                      {lines.map((l, i) => <div key={i} style={{ color: 'var(--text-2)' }}>{l}</div>)}
                      {!lines.length && !stateChanged && (
                        <span style={{ color: 'var(--text-4)' }}>mentioned, nothing changed</span>
                      )}
                      {e.evidence_quote && (
                        <div style={{ color: 'var(--text-4)', fontStyle: 'italic', marginTop: 2 }}>
                          “{e.evidence_quote.slice(0, 160)}”{e.intent ? ` · ${e.intent}` : ''}
                          {e.post_id && onViewMessage && (
                            <span onClick={() => onViewMessage(e.post_id)}
                              style={{ marginLeft: 6, fontStyle: 'normal', color: 'var(--info)', cursor: 'pointer' }}
                              title="Open the stored message">↗ source</span>
                          )}
                        </div>
                      )}
                    </div>
                  </div>
                );
              })}
            </div>
          ) : <div style={{ fontSize: 11, color: 'var(--text-4)' }}>No recorded changes yet — just the original setup.</div>
        )}
      </td>
    </tr>
  );
}

// ── health strip ───────────────────────────────────────────────────────────
function Dot({ ok, title }) {
  return <span title={title} style={{
    width: 7, height: 7, borderRadius: '50%', display: 'inline-block', flexShrink: 0,
    background: ok ? 'var(--bull)' : 'var(--bear)',
  }} />;
}

function pnlColor(v) { return v > 0 ? 'var(--bull)' : v < 0 ? 'var(--bear)' : 'var(--text)'; }

function ledgerChip(active) {
  return {
    padding: '3px 9px', borderRadius: 6, fontSize: 11, cursor: 'pointer', fontFamily: 'var(--font-sans)',
    background: active ? 'var(--gold, #d4a017)' : 'transparent',
    border: '1px solid ' + (active ? 'var(--gold, #d4a017)' : 'var(--border-soft)'),
    color: active ? 'var(--bg-base, #1a1a2e)' : 'var(--text-3)', fontWeight: active ? 700 : 400,
  };
}
const dashLabelStyle = {
  fontSize: 9.5, color: 'var(--text-4)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 3, display: 'block',
};
const dashHint = { fontSize: 10.5, color: 'var(--text-4)', lineHeight: 1.5, marginTop: 5 };

// Windows Realized P&L, entry slippage and the History tab — see the doc comment
// on GET /api/signals/strategy-pnl for why Unrealized/open positions are excluded.
const PERIOD_CHIPS = [['1w', '1W'], ['1m', '1M'], ['3m', '3M'], ['6m', '6M'], ['1y', '1Y'], ['all', 'All'], ['custom', 'Custom']];
const PERIOD_LABEL = {
  '1w': 'last 7 days', '1m': 'last 30 days', '3m': 'last 3 months', '6m': 'last 6 months',
  '1y': 'last year', all: 'all-time', custom: 'custom range',
};

// One filter bar at the top of the trader page driving matched/env/period across
// the whole page (widgets, ledger table, and the History tab) — previously
// matched/env lived inside the ledger card only, easy to miss since they quietly
// affected the P&L widgets above without being visible next to them.
function TopFilterBar({ matchedOnly, setMatchedOnly, envFilter, setEnvFilter, period, setPeriod, customSince, setCustomSince, customUntil, setCustomUntil, viewMode, includeOrphans, setIncludeOrphans, sideFilter, setSideFilter, driftFilter, setDriftFilter, accountFilter, setAccountFilter, accountOptions, openedSince, setOpenedSince, openedUntil, setOpenedUntil }) {
  const dateInp = {
    padding: '4px 7px', borderRadius: 6, fontSize: 11, background: 'var(--bg-elev)',
    border: '1px solid var(--border-soft)', color: 'var(--text)', fontFamily: 'var(--font-mono)',
  };
  const selectInp = { ...dateInp, fontFamily: 'var(--font-sans)' };
  return (
    <div className="card" style={{ display: 'flex', gap: 18, alignItems: 'center', flexWrap: 'wrap', padding: '10px 14px', marginBottom: 14 }}>
      <button onClick={() => setMatchedOnly(v => !v)} title={viewMode === 'admin'
        ? "Hide ledger-only rows — only count signals where a following account actually holds a matched position on the exchange"
        : "Show only trades that are applied to your account"} style={ledgerChip(matchedOnly)}>
        {viewMode === 'admin' ? 'Matched with exchange only' : 'My trades only'}
      </button>
      <button onClick={() => setIncludeOrphans(v => !v)}
        title="Include trades where the strategy closed/expired the signal but a position is still open on the exchange — these need manual attention"
        style={ledgerChip(includeOrphans)}>
        {includeOrphans ? '⚠ On exchange' : 'On exchange'}
      </button>
      <span style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
        <span style={{ fontSize: 10.5, color: 'var(--text-4)' }} title="Bybit venue funds: Demo = practice, Real money = real funds. Separate from order mode (Live orders vs Paper).">Account type</span>
        {[['all', 'All'], ['demo', 'Demo'], ['real', 'Real money']].map(([id, label]) => (
          <button key={id} onClick={() => setEnvFilter(id)} style={ledgerChip(envFilter === id)}>{label}</button>
        ))}
      </span>
      {setSideFilter && (
        <span style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
          <span style={{ fontSize: 10.5, color: 'var(--text-4)' }}>Side</span>
          {[['all', 'All'], ['long', 'Long'], ['short', 'Short']].map(([id, label]) => (
            <button key={id} onClick={() => setSideFilter(id)} style={ledgerChip(sideFilter === id)}>{label}</button>
          ))}
        </span>
      )}
      {setDriftFilter && (
        <span style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
          <span style={{ fontSize: 10.5, color: 'var(--text-4)' }}
                title="Matched: a following account holds a real position on it. Drift: ledger-only, no account currently holds a matched position.">
            Drift
          </span>
          {[['all', 'All'], ['matched', 'Matched'], ['drift', 'Drift only']].map(([id, label]) => (
            <button key={id} onClick={() => setDriftFilter(id)} style={ledgerChip(driftFilter === id)}>{label}</button>
          ))}
        </span>
      )}
      {setAccountFilter && accountOptions && accountOptions.length > 0 && (
        <span style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
          <span style={{ fontSize: 10.5, color: 'var(--text-4)' }}>Account</span>
          <select value={accountFilter} onChange={e => setAccountFilter(e.target.value)} style={selectInp}>
            <option value="all">All accounts</option>
            {accountOptions.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
          </select>
        </span>
      )}
      <span style={{ display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' }}>
        <span style={{ fontSize: 10.5, color: 'var(--text-4)' }}
              title="Windows Realized P&L, entry slippage, and the History tab. Unrealized P&L, open allocation, and the open ledger are always 'right now' and aren't affected.">
          Period ⓘ
        </span>
        {PERIOD_CHIPS.map(([id, label]) => (
          <button key={id} onClick={() => setPeriod(id)} style={ledgerChip(period === id)}>{label}</button>
        ))}
        {period === 'custom' && (
          <span style={{ display: 'flex', gap: 6, alignItems: 'center', marginLeft: 2 }}>
            <input type="date" value={customSince} onChange={e => setCustomSince(e.target.value)} style={dateInp} />
            <span style={{ color: 'var(--text-4)', fontSize: 11 }}>to</span>
            <input type="date" value={customUntil} onChange={e => setCustomUntil(e.target.value)} style={dateInp} />
          </span>
        )}
      </span>
      {setOpenedSince && (
        <span style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
          <span style={{ fontSize: 10.5, color: 'var(--text-4)' }} title="Filters the ledger table itself by when each signal was opened — separate from Period above.">Opened</span>
          <input type="date" value={openedSince} onChange={e => setOpenedSince(e.target.value)} style={dateInp} />
          <span style={{ color: 'var(--text-4)', fontSize: 11 }}>to</span>
          <input type="date" value={openedUntil} onChange={e => setOpenedUntil(e.target.value)} style={dateInp} />
          {(openedSince || openedUntil) && (
            <button onClick={() => { setOpenedSince(''); setOpenedUntil(''); }}
              style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--gold)', fontSize: 11 }}>✕</button>
          )}
        </span>
      )}
    </div>
  );
}

// Simple donut, no library — same stroke-dasharray-on-a-circle trick everywhere
// this kind of chart gets hand-rolled. Colours come from CAT_COLORS (see the note
// at the top of this file on why bull/bear are never used for a category).
function StrategyDonut({ allocation }) {
  const total = (allocation || []).reduce((s, a) => s + a.notional, 0);
  if (!total) return <div style={{ color: 'var(--text-4)', fontSize: 11.5, textAlign: 'center', padding: '22px 0' }}>No open exposure right now.</div>;

  const shown = allocation.slice(0, CAT_COLORS.length).map((a, i) => ({ ...a, color: CAT_COLORS[i] }));
  const rest = allocation.slice(CAT_COLORS.length);
  if (rest.length) shown.push({ symbol: `Other (${rest.length})`, notional: rest.reduce((s, r) => s + r.notional, 0), color: CAT_OTHER });

  let acc = 0;
  const segs = shown.map(s => {
    const pct = (s.notional / total) * 100;
    const seg = { ...s, pct, offset: acc };
    acc += pct;
    return seg;
  });

  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
      <svg viewBox="0 0 36 36" style={{ width: 72, height: 72, flexShrink: 0, transform: 'rotate(-90deg)' }}>
        <circle cx="18" cy="18" r="15.9155" fill="none" stroke="var(--bg-elev)" strokeWidth="4" />
        {segs.map((s, i) => (
          <circle key={i} cx="18" cy="18" r="15.9155" fill="none" stroke={s.color} strokeWidth="4"
                  strokeDasharray={`${s.pct} ${100 - s.pct}`} strokeDashoffset={-s.offset} />
        ))}
      </svg>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: 1, minWidth: 0 }}>
        {segs.map((s, i) => (
          <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11 }}>
            <span style={{ width: 8, height: 8, borderRadius: 2, background: s.color, flexShrink: 0 }} />
            <span style={{ color: 'var(--text-2)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.symbol}</span>
            <span className="mono" style={{ color: 'var(--text-4)' }}>{s.pct.toFixed(0)}%</span>
          </div>
        ))}
      </div>
    </div>
  );
}

// Unrealized P&L + open allocation, derived client-side from the SAME filtered
// ledger rows the table below renders — so these numbers can never disagree with
// the table, and so they mean something even for a trader nobody currently follows.
// The percentage is the simple average of each open signal's live paper move (the
// same figure the ledger's own "paper P&L" column shows, r.paper.pnl_pct) — a
// hypothetical follower's return, available whether or not any account actually
// holds the position. The dollar figure alongside it is the real thing: the sum of
// this account's actual unrealized_pnl across matched positions, which is honestly
// zero when nothing is allocated rather than hidden or estimated.
function deriveWidgetPnl(filteredLedger) {
  let pctSum = 0, pctN = 0, missing = 0, dollarTotal = 0;
  const allocation = {};
  (filteredLedger || []).forEach(r => {
    const pct = r.paper && r.paper.pnl_pct;
    if (pct == null) missing++; else { pctSum += pct; pctN++; }
    let hasRealPos = false;
    (r.open || []).forEach(p => {
      if (p.unrealized_pnl != null) dollarTotal += Number(p.unrealized_pnl);
      const fill = Number(p.avg_fill), qty = Number(p.filled_qty);
      if (Number.isFinite(fill) && Number.isFinite(qty) && fill * qty > 0) {
        allocation[r.symbol] = (allocation[r.symbol] || 0) + fill * qty;
        hasRealPos = true;
      }
    });
    if (!hasRealPos && r.symbol && (r.state === 'LIVE' || r.state === 'PARTIAL')) {
      const ref = Number(r.entry_price || r.reference_price);
      if (Number.isFinite(ref) && ref > 0) {
        allocation[r.symbol] = (allocation[r.symbol] || 0) + ref;
      }
    }
  });
  return {
    avg_pct: pctN ? pctSum / pctN : null,
    n: pctN, missing_n: missing, dollar_total: dollarTotal,
    allocation: Object.keys(allocation).map(symbol => ({ symbol, notional: allocation[symbol] }))
      .sort((a, b) => b.notional - a.notional),
  };
}

// Operational glance at this trader across EVERY account following it, not just
// the admin's own — realized/slippage/followers come from GET /api/signals/strategy-pnl
// (env+period-filtered server-side); unrealized/allocation come from filteredLedger,
// the same matched+env-filtered rows LedgerTable renders (period doesn't apply to
// them — see the doc comment on the endpoint). The Compare Strategies page is the
// deeper analysis tool; this is a filtered status snapshot.
function StrategyPnlRow({ strategyId, filteredLedger, envFilter, period, customSince, customUntil, marksStale, viewMode, onShowTrades }) {
  const [data, setData] = useS(null);
  const [err, setErr] = useS(null);

  useE(() => {
    if (!strategyId) return undefined;
    let alive = true;
    const fetchNow = () => {
      const qs = new URLSearchParams();
      if (envFilter !== 'all') qs.set('env', envFilter);
      if (period === 'custom') {
        if (customSince) qs.set('since', customSince);
        if (customUntil) qs.set('until', customUntil);
      } else if (period !== 'all') {
        qs.set('period', period);
      }
      const qStr = qs.toString() ? '?' + qs.toString() : '';
      const pnlPath = viewMode === 'admin'
        ? sigWithStrategy('/api/signals/strategy-pnl' + qStr, strategyId)
        : meStrategyUrl(strategyId, 'pnl') + qStr;
      const reportPath = viewMode === 'admin'
        ? sigWithStrategy('/api/signals/strategy-report' + qStr, strategyId)
        : meStrategyUrl(strategyId, 'report') + qStr;
      Promise.all([
        fetch(pnlPath).then(r => r.json()),
        fetch(reportPath).then(r => r.json()),
      ]).then(([pnl, rpt]) => {
        if (!alive) return;
        if (pnl.ok && rpt.ok) {
          // The trades behind the Realized tile (scored ones only — the same set the W/L counts use).
          const trades = (rpt.data.trades || []).filter(t => t.net_r != null).map(t => ({ id: t.signal_id, win: t.net_r > 0 }));
          setData({ ...pnl.data, summary: rpt.data.summary, trades });
          setErr(null);
        } else {
          setErr(pnl.error || rpt.error || 'unknown');
        }
      }).catch(e => alive && setErr(String(e.message || e)));
    };
    fetchNow(); // always — a freshly mounted widget loads regardless of tab visibility
    const id = setInterval(() => { if (!document.hidden) fetchNow(); }, 10000);
    return () => { alive = false; clearInterval(id); };
  }, [strategyId, envFilter, period, customSince, customUntil, viewMode]);

  if (!strategyId) return null; // only meaningful scoped to one trader
  if (err) return <div className="card" style={{ marginBottom: 14 }}><div className="card-b" style={{ color: 'var(--bear)', fontSize: 12 }}>{err}</div></div>;
  if (!data || filteredLedger == null) return null;

  const isAdmin = viewMode === 'admin';
  const u = deriveWidgetPnl(filteredLedger);
  const cols = isAdmin ? 4 : 3;
  const grid4 = { display: 'grid', gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: 14, marginBottom: 14 };
  const rptSummary = data.summary || {};
  const s = data.slippage || { avg_pct: null, n: 0, excluded_n: 0, total_cost_usd: null };
  const f = data.followers || { n: 0, live_n: 0 };

  return (
    <div className="rg-kpi" style={grid4}>
      <div className="card">
        <div className="card-h">
          <span style={{ fontWeight: 600, fontSize: 13 }}>{isAdmin ? 'P&L across followers' : 'Your account P&L'}</span>
          {marksStale && <span className="chip gold" style={{ fontSize: 9.5, marginLeft: 8 }} title="Bybit didn't answer just now; showing the last prices we had.">stale price</span>}
        </div>
        <div className="card-b">
          <span style={dashLabelStyle} title="Paper %: average live % move of every open signal below (same paper P&L column as the ledger) — a hypothetical follower's return, even if you don't follow this trader. Account $ in parentheses: sum of your accounts' real unrealized P&L on matched positions (honestly $0 when nothing is allocated).">Unrealized · paper % (account $) ⓘ</span>
          <div className="mono" style={{ fontSize: 18, fontWeight: 600, color: u.avg_pct != null ? pnlColor(u.avg_pct) : undefined }}>
            {u.avg_pct != null
              ? <>{signedPct(u.avg_pct)} <span style={{ fontSize: 12, opacity: 0.75 }}>({fmtUsd(u.dollar_total)})</span></>
              : '—'}
          </div>
          {u.missing_n > 0 && <div style={dashHint}>{u.missing_n} signal{u.missing_n === 1 ? '' : 's'} without a live price</div>}
          <span style={{ ...dashLabelStyle, marginTop: 9 }}>Realized ({PERIOD_LABEL[period] || 'all-time'})</span>
          {(() => {
            const sm = rptSummary;
            return <>
              <div className="mono" style={{ fontSize: 18, fontWeight: 600, color: sm && sm.total_r != null ? pnlColor(sm.total_r) : undefined }}
                   title="R = one unit of the risk taken on a trade (entry to stop). Total and average are net of fees, across the scored trades below.">
                {sm && sm.total_r != null
                  ? <>{signedR(sm.total_r)} <span style={{ fontSize: 12, opacity: 0.75 }}>{sm.avg_r != null ? signedR(sm.avg_r) + ' avg' : ''}</span></>
                  : '—'}
              </div>
              {sm && sm.account_pct_at_1pct_risk != null && (
                <div style={dashHint} title="What the balance would have done risking 1% of it on every trade, compounded in close order. Scale roughly by your own risk setting.">
                  ≈ {signedPct(sm.account_pct_at_1pct_risk)} of an account at 1% risk per trade
                </div>
              )}
              {sm && sm.n > 0 && (() => {
                const trades = data.trades || [];
                const link = { background: 'none', border: 'none', padding: 0, font: 'inherit', color: 'var(--text-3)',
                               textDecoration: 'underline dotted', cursor: onShowTrades ? 'pointer' : 'default' };
                const show = (kind, pick) => () => onShowTrades && onShowTrades({
                  ids: trades.filter(pick).map(t => t.id),
                  label: `Realized P&L · ${PERIOD_LABEL[period] || 'all-time'} · ${kind}`,
                });
                return (
                  <div style={dashHint}>
                    <button style={link} onClick={show('all trades', () => true)} title="Show these trades in History">
                      {sm.n} trade{sm.n === 1 ? '' : 's'}
                    </button>
                    {' · '}
                    <button style={link} onClick={show('wins', t => t.win)} title="Show the winning trades in History">{sm.wins}W</button>
                    {'/'}
                    <button style={link} onClick={show('losses', t => !t.win)} title="Show the losing trades in History">{sm.losses}L</button>
                  </div>
                );
              })()}
            </>;
          })()}
        </div>
      </div>

      <div className="card">
        <div className="card-h">
          <span style={{ fontWeight: 600, fontSize: 13 }} title="Only symbols a following account actually holds a real position in right now — not every setup the trader currently claims is open. The ledger below can show more symbols than this: a 'ledger-only' row (no free slot, no live account following yet, ...) has no position backing it, so it isn't counted here.">
            Open allocation ⓘ
          </span>
        </div>
        <div className="card-b"><StrategyDonut allocation={u.allocation} /></div>
      </div>

      <div className="card">
        <div className="card-h"><span style={{ fontWeight: 600, fontSize: 13 }}>Entry slippage</span></div>
        <div className="card-b">
          <div className="mono" style={{ fontSize: 18, fontWeight: 600, color: s.avg_pct != null ? pnlColor(-s.avg_pct) : undefined }}>
            {s.avg_pct != null ? signedPct(s.avg_pct) : '—'}
          </div>
          <div style={dashHint} title="How far the actual fill landed from the price the trader stated, averaged across every follower's trade — positive means fills landed worse than called. CMP/DCA setups have no stated price and aren't counted.">
            {s.avg_pct != null
              ? `${fmtUsd(s.total_cost_usd)} across ${s.n} trade${s.n === 1 ? '' : 's'} ⓘ`
              : 'No trades with a stated entry yet'}
            {s.excluded_n > 0 && <span> · {s.excluded_n} market-entry excluded</span>}
          </div>
        </div>
      </div>

      {isAdmin && f && <div className="card">
        <div className="card-h">
          <span style={{ fontWeight: 600, fontSize: 13 }} title="Operator strip — how many accounts follow this strategy. Not shown on Strategy (investor) view.">Followers</span>
        </div>
        <div className="card-b">
          <div className="mono" style={{ fontSize: 18, fontWeight: 600 }}>{f.n}</div>
          <div style={dashHint} title="Live orders = account mode sends orders to Bybit. Paper = records only, no orders.">{f.live_n} live orders, {f.n - f.live_n} paper</div>
        </div>
      </div>}
    </div>
  );
}

function HealthStrip({ health, control, onKill }) {
  const h = health || {};
  const readerOk = !isStale(h.reader_heartbeat, 300);
  const workerOk = !isStale(h.worker_heartbeat, 600);
  const killed = String((control && control.kill_switch) || '0') === '1';
  const lastMsg = h.last_message_unixtime
    ? agoFrom(new Date(h.last_message_unixtime * 1000).toISOString()) : '—';

  return (
    <div style={{
      position: 'sticky', top: 0, zIndex: 5, display: 'flex', alignItems: 'center',
      gap: 16, flexWrap: 'wrap', padding: '9px 14px', marginBottom: 14,
      background: 'var(--bg-card)', border: '1px solid var(--border-soft)',
      borderRadius: 'var(--r)', fontSize: 11.5,
    }} title="Operator hero — pipeline trust. Strategy view hides this strip.">
      <span style={{
        fontWeight: 600, fontSize: 11, letterSpacing: '0.08em', textTransform: 'uppercase',
        color: 'var(--gold)', marginRight: 4,
      }}>Pipeline trust</span>
      <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
        <Dot ok={readerOk} title={h.reader_heartbeat || 'never'} />
        reader <span style={{ color: 'var(--text-4)' }}>{agoFrom(h.reader_heartbeat)}</span>
      </span>
      <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
        <Dot ok={workerOk} title={h.worker_heartbeat || 'never'} />
        worker <span style={{ color: 'var(--text-4)' }}>{agoFrom(h.worker_heartbeat)}</span>
      </span>
      <span style={{ color: 'var(--text-3)' }}>
        last msg <span className="mono">{lastMsg}</span>
      </span>
      <span style={{ color: h.backlog > 50 ? 'var(--gold)' : 'var(--text-3)' }}>
        backlog <span className="mono">{h.backlog ?? '—'}</span>
      </span>
      <span style={{ color: 'var(--text-3)' }}>
        decisions <span className="mono">{(h.counts && h.counts.decisions) ?? '—'}</span>
      </span>
      <div style={{ flex: 1 }} />
      {h.worker_last_error && (
        <span title={h.worker_last_error} style={{
          color: 'var(--bear)', maxWidth: 260, overflow: 'hidden',
          textOverflow: 'ellipsis', whiteSpace: 'nowrap',
        }}>⚠ {h.worker_last_error}</span>
      )}
      <button onClick={onKill} title="Pipeline stop: no account places new entries while this is on." style={{
        padding: '4px 11px', borderRadius: 6, cursor: 'pointer', fontSize: 11,
        fontFamily: 'var(--font-sans)', fontWeight: 600,
        background: killed ? 'var(--bear)' : 'transparent',
        color: killed ? '#fff' : 'var(--bear)',
        border: '1px solid var(--bear)',
      }}>{killed ? 'All accounts stopped — resume' : 'Stop all accounts'}</button>
    </div>
  );
}

// ── equity history: single series, so no legend, direct label, crosshair ───
function EquityChart({ rows, height = 190 }) {
  const [hover, setHover] = useS(null);
  const pts = (rows || []).filter(r => Number.isFinite(Number(r.equity_usd)));
  if (pts.length < 2) {
    return (
      <div style={{ height, display: 'grid', placeItems: 'center', color: 'var(--text-4)', fontSize: 12 }}>
        {pts.length ? 'Only one snapshot so far — the line needs a few more.' : 'No snapshots yet.'}
      </div>
    );
  }
  const W = 760, H = height, padL = 8, padR = 62, padT = 12, padB = 20;
  const vals = pts.map(p => Number(p.equity_usd));
  let lo = Math.min(...vals), hi = Math.max(...vals);
  if (hi - lo < 1e-9) { hi = lo + 1; lo -= 1; }
  const pad = (hi - lo) * 0.12; lo -= pad; hi += pad;
  const x = i => padL + (i / (pts.length - 1)) * (W - padL - padR);
  const y = v => padT + (1 - (v - lo) / (hi - lo)) * (H - padT - padB);

  const d = vals.map((v, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
  const area = `${d} L${x(pts.length - 1).toFixed(1)},${y(lo)} L${x(0).toFixed(1)},${y(lo)} Z`;
  const first = vals[0], last = vals[vals.length - 1];
  const up = last >= first;
  const stroke = up ? 'var(--bull)' : 'var(--bear)';

  function onMove(e) {
    const r = e.currentTarget.getBoundingClientRect();
    const px = ((e.clientX - r.left) / r.width) * W;
    let i = Math.round(((px - padL) / (W - padL - padR)) * (pts.length - 1));
    i = Math.max(0, Math.min(pts.length - 1, i));
    setHover(i);
  }

  return (
    <div style={{ position: 'relative' }}>
      <svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height, display: 'block' }}
           onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
        <defs>
          <linearGradient id="eqfill" x1="0" y1="0" x2="0" y2="1">
            <stop offset="0%" stopColor={stroke} stopOpacity="0.22" />
            <stop offset="100%" stopColor={stroke} stopOpacity="0" />
          </linearGradient>
        </defs>
        {/* recessive grid */}
        {[0, 0.5, 1].map(f => (
          <line key={f} x1={padL} x2={W - padR} y1={padT + f * (H - padT - padB)}
                y2={padT + f * (H - padT - padB)}
                stroke="var(--border-soft)" strokeWidth="1" />
        ))}
        <path d={area} fill="url(#eqfill)" />
        <path d={d} fill="none" stroke={stroke} strokeWidth="2"
              strokeLinejoin="round" strokeLinecap="round" />
        {/* direct label instead of a y-axis of numbers */}
        <text x={W - padR + 8} y={y(last) + 4} fill="var(--text)" fontSize="12"
              fontFamily="var(--font-mono)">{fmtUsd(last)}</text>
        {hover != null && (
          <g>
            <line x1={x(hover)} x2={x(hover)} y1={padT} y2={H - padB}
                  stroke="var(--text-4)" strokeWidth="1" strokeDasharray="3 3" />
            <circle cx={x(hover)} cy={y(vals[hover])} r="4" fill={stroke}
                    stroke="var(--bg-card)" strokeWidth="2" />
          </g>
        )}
      </svg>
      {hover != null && (
        <div style={{
          position: 'absolute', top: 4, left: 8, pointerEvents: 'none',
          background: 'var(--bg-elev)', border: '1px solid var(--border)',
          borderRadius: 6, padding: '5px 9px', fontSize: 11,
        }}>
          <div className="mono" style={{ color: 'var(--text)' }}>{fmtUsd(vals[hover])}</div>
          <div style={{ color: 'var(--text-4)', fontSize: 10 }}>
            {(pts[hover].at || '').replace('T', ' ').replace('Z', '')}
          </div>
        </div>
      )}
    </div>
  );
}

// ── allocation: categorical, validated hues, bar not pie ───────────────────
function Allocation({ acct }) {
  const [hover, setHover] = useS(null);
  const balances = (acct && acct.balances) || {};
  const positions = (acct && acct.open_positions) || [];

  const parts = [];
  const cash = Number(balances.USDT && balances.USDT.total) || 0;
  positions.forEach(p => {
    const n = Number(p.notional_usd);
    if (Number.isFinite(n) && n > 0) {
      parts.push({ label: String(p.symbol || '').split('/')[0], value: n, kind: 'position' });
    }
  });
  Object.keys(balances).forEach(a => {
    if (a === 'USDT') return;
    const qty = Number(balances[a].total) || 0;
    if (qty <= 0) return;
    const usd = Number(balances[a].usd_value);
    // Prefer the exchange's own USD valuation so a BTC/ETH holding sits on the
    // same dollar scale as cash and position notional; only fall back to raw
    // quantity (clearly marked "approx") when the exchange didn't report one.
    if (Number.isFinite(usd) && usd > 0) {
      parts.push({ label: a, value: usd, kind: 'balance', qty });
    } else {
      parts.push({ label: a, value: qty, kind: 'balance', approx: true });
    }
  });
  parts.sort((a, b) => b.value - a.value);

  // Fixed order, never cycled: beyond 3 categories everything folds into Other.
  const shown = parts.slice(0, CAT_COLORS.length).map((p, i) => ({ ...p, color: CAT_COLORS[i] }));
  const rest = parts.slice(CAT_COLORS.length);
  if (rest.length) {
    shown.push({ label: `Other (${rest.length})`, value: rest.reduce((s, r) => s + r.value, 0), color: CAT_OTHER });
  }
  if (cash > 0) shown.push({ label: 'USDT cash', value: cash, color: 'var(--text-4)', cash: true });

  const total = shown.reduce((s, p) => s + p.value, 0);
  if (!total) {
    return <div style={{ padding: '26px 0', textAlign: 'center', color: 'var(--text-4)', fontSize: 12 }}>
      No balances to break down yet.
    </div>;
  }

  return (
    <div>
      <div style={{ display: 'flex', gap: 2, height: 26, borderRadius: 5, overflow: 'hidden', marginBottom: 12 }}>
        {shown.map((p, i) => (
          <div key={i} title={`${p.label} ${p.approx ? fmtNum(p.value) : fmtUsd(p.value)}`}
               onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}
               style={{
                 width: `${(p.value / total) * 100}%`, background: p.color,
                 opacity: hover == null || hover === i ? 1 : 0.45,
                 transition: 'opacity .12s', cursor: 'default',
               }} />
        ))}
      </div>
      {/* legend always present, and every slice is directly labelled */}
      <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
        {shown.map((p, i) => (
          <div key={i} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}
               style={{
                 display: 'flex', alignItems: 'center', gap: 8, fontSize: 11.5,
                 opacity: hover == null || hover === i ? 1 : 0.55,
               }}>
            <span style={{ width: 9, height: 9, borderRadius: 2, background: p.color, flexShrink: 0 }} />
            <span style={{ color: 'var(--text-2)', flex: 1 }}>
              {p.label}
              {p.kind === 'balance' && !p.approx && (
                <span style={{ color: 'var(--text-4)', fontSize: 10 }}> · {fmtNum(p.qty)}</span>
              )}
              {p.approx && <span style={{ color: 'var(--text-4)', fontSize: 10 }}> · qty, not valued</span>}
            </span>
            <span className="mono" style={{ color: 'var(--text-3)' }}>
              {((p.value / total) * 100).toFixed(1)}%
            </span>
            <span className="mono" style={{ color: 'var(--text)', minWidth: 78, textAlign: 'right' }}>
              {p.approx ? fmtNum(p.value) : fmtUsd(p.value)}
            </span>
          </div>
        ))}
      </div>
    </div>
  );
}

// ── timestamps ─────────────────────────────────────────────────────────────
// A bare "2026-10-06 16:05:00" is UTC by convention here, so it gets a Z; a value that
// already says its zone ("...Z", "...+00:00") is parsed as is — appending a Z to one with an
// offset made it unparseable and every such time rendered as "—".
function parseIso(iso) {
  if (!iso) return null;
  const s = String(iso);
  const t = Date.parse(/(Z|[+-]\d\d:?\d\d)$/i.test(s) ? s : s + 'Z');
  return Number.isFinite(t) ? t : null;
}

// "1d 21h", "3h 12m", "8m": precise enough to read an open trade's age at a glance.
function ageLong(iso) {
  const t = parseIso(iso);
  if (t === null) return '—';
  const s = Math.max(0, (Date.now() - t) / 1000);
  const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
  if (d > 0) return d + 'd ' + h + 'h';
  if (h > 0) return h + 'h ' + m + 'm';
  return m + 'm';
}

function fmtStamp(iso) {
  const t = parseIso(iso);
  if (t === null) return '—';
  return new Date(t).toLocaleString('en-GB', {
    timeZone: 'UTC', day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit', hour12: false,
  }) + ' UTC';
}

// "4h 01m": the gap between the trader posting and the signal reaching us.
function gapLong(fromIso, toIso) {
  const a = parseIso(fromIso), b = parseIso(toIso);
  if (a === null || b === null) return null;
  const m = Math.round(Math.abs(b - a) / 60000);
  return Math.floor(m / 60) + 'h ' + String(m % 60).padStart(2, '0') + 'm';
}

function OpenedCell({ r }) {
  const gap = gapLong(r.trader_posted_utc, r.created_at);
  const tip = [
    'Paper entry: this signal reached us at ' + fmtStamp(r.created_at) + '.',
    r.entry_price != null ? 'The entry price (' + fmtNum(r.entry_price) + ') is the market price at that moment.' : null,
    r.trader_posted_utc
      ? 'The trader posted it at ' + fmtStamp(r.trader_posted_utc) + (gap ? ', ' + gap + ' earlier: the source channel is a delayed mirror.' : '.')
      : null,
    'Nothing was executed on the exchange (shadow mode).',
  ].filter(Boolean).join(' ');
  return (
    <span title={tip}>
      <span style={{ color: 'var(--text-2)' }}>{ageLong(r.created_at)}</span>
      <span style={{ display: 'block', fontSize: 9.5, color: 'var(--text-4)' }}>{fmtStamp(r.created_at)}</span>
    </span>
  );
}

// ── entry eligibility marker ───────────────────────────────────────────────
// Live trading would have skipped this setup. Paper records it anyway so the scorecard
// can compare the two groups, and this is where that verdict becomes visible.
const ELIG_WHY = {
  DRIFT_EXCEEDS_TOLERANCE: 'price had already run past the entry band by the time it reached us',
  REMAINING_RR_BELOW_FLOOR: 'too little reward was left for the risk',
  SL_TOUCHED_SINCE_POST: 'price had already hit the stop after he posted it',
  TP1_REACHED_SINCE_POST: 'price had already reached the first target after he posted it',
  PULLED_BACK_BEYOND_TOLERANCE: 'price had pulled back beyond the entry band',
  AGE_BACKSTOP: 'it was too old by the time it reached us',
  PRICE_PATH_UNAVAILABLE: 'there was no price data to check it against (fails closed)',
  STOP_NOT_ON_LOSING_SIDE_OF_REFERENCE: 'the stop was on the wrong side of the entry',
};

function EligMark({ r }) {
  if (r.eligible !== 0) return null;          // eligible, or never assessed: nothing to say
  const why = ELIG_WHY[r.eligibility_reason] || r.eligibility_reason || 'ineligible';
  const drift = r.drift_r != null ? ' Drift from his entry: ' + (r.drift_r >= 0 ? '+' : '−') + Math.abs(r.drift_r).toFixed(2) + 'R.' : '';
  return (
    <span title={'Live trading would have skipped this: ' + why + '.' + drift +
      ' Paper records it anyway, so eligible and ineligible setups can be compared.'}
      style={{ marginLeft: 6, fontSize: 9, color: 'var(--gold)', cursor: 'help' }}>would skip live</span>
  );
}

// ── live price, flashing on a tick ─────────────────────────────────────────
// Colour follows the direction of the last move (bull/bear are reserved for exactly
// this sign), and fades. Nothing flashes on first load: there is no prior price.
function LivePrice({ value, entry }) {
  const prev = useR(null);
  const [dir, setDir] = useS(null);

  useE(() => {
    if (value == null) return undefined;
    const was = prev.current;
    prev.current = value;
    if (was == null || value === was) return undefined;
    setDir(value > was ? 'up' : 'down');
    const id = setTimeout(() => setDir(null), 1200);
    return () => clearTimeout(id);
  }, [value]);

  const tone = dir === 'up' ? 'var(--bull)' : dir === 'down' ? 'var(--bear)' : 'var(--text)';
  const bg = dir === 'up' ? 'var(--bull-bg)' : dir === 'down' ? 'var(--bear-bg)' : 'transparent';
  return (
    <span title="Bybit mark price, refreshed every 10 seconds.">
      <span style={{
        color: tone, background: bg, borderRadius: 4, padding: '1px 5px', marginLeft: -5,
        transition: 'background 1.1s ease-out, color 1.1s ease-out',
      }}>
        {(dir === 'up' ? '▲ ' : dir === 'down' ? '▼ ' : '') + fmtNum(value)}
      </span>
      {entry != null && (
        <span style={{ marginLeft: 6, fontSize: 10, color: 'var(--text-4)' }}>(entry {fmtNum(entry)})</span>
      )}
    </span>
  );
}

// ── paper P&L cell ─────────────────────────────────────────────────────────
const ENTRY_BASIS = {
  market_when_seen: 'the market price when the signal reached us',
  stated: "the trader's stated limit price",
  stated_bound: "the trader's stated bound (no market price was available)",
};

function signedPct(v) { return (v >= 0 ? '+' : '−') + Math.abs(v).toFixed(2) + '%'; }
function signedR(v) { return (v >= 0 ? '+' : '−') + Math.abs(v).toFixed(2) + 'R'; }

// A trade that enters in pieces is not fully in just because one piece is: say how much is. Single
// entries show nothing. ("Partly closed" is a different thing: the trader took profit.)
function EntryProgress({ r }) {
  if (r.state !== 'LIVE' && r.state !== 'PARTIAL') return null;
  let text = null, tip = '';
  if (r.split_legs > 1 && r.legs_reached != null) {
    text = `entries ${r.legs_reached}/${r.split_legs} reached`;
    tip = `This trade enters in ${r.split_legs} pieces and ${r.legs_reached} of them have been reached so far. This row is one piece.`;
  } else if (r.entry_type === 'dca_zone' && r.entry_price_source === 'market_when_seen') {
    text = 'averaging in';
    tip = 'Started at the market, with more buys planned lower down to the zone the trader gave ("CMP till X"), so it is not at its full planned size yet.';
  }
  if (!text) return null;
  return <span title={tip} style={{ display: 'block', marginTop: 2, fontSize: 9, color: 'var(--text-3)', fontWeight: 600 }}>{text}</span>;
}

function PaperPnl({ r }) {
  const p = r.paper;
  // A resting limit/split entry that price hasn't reached yet since the signal
  // arrived — nothing is in yet, so there is no P&L to mark, hypothetical or
  // real. Shown distinctly from "no data" below: this IS data, it's just "not yet".
  if (p && p.pending) {
    return <span title={`This is a ${r.entry_type} entry at ${fmtNum(r.entry_price)} — price hasn't reached it since the signal arrived, so the entry has not been reached yet.`}
      style={{ color: 'var(--text-4)', fontStyle: 'italic' }}>pending entry</span>;
  }
  // No entry price or no live mark: say so. A blank would read as flat, and 0 as a
  // real result.
  if (!p || p.pnl_pct == null) {
    return <span title={r.entry_price == null
      ? 'No entry price was recorded for this signal, so there is nothing to mark.'
      : 'No live price for this symbol right now.'} style={{ color: 'var(--text-4)' }}>—</span>;
  }
  const up = p.pnl_pct >= 0;
  const color = Math.abs(p.pnl_pct) < 0.005 ? 'var(--text-3)' : up ? 'var(--bull)' : 'var(--bear)';
  const tip = [
    'Paper P&L: what a follower who entered at the signal would be up or down now.',
    `Entry ${fmtNum(r.entry_price)} — ${ENTRY_BASIS[p.basis] || 'source unknown'}.`,
    r.entry_filled_at ? `Entry reached at ${fmtStamp(r.entry_filled_at)}.` : null,
    `Mark ${fmtNum(p.mark)} (Bybit mark price).`,
    p.pnl_r != null
      ? `R is measured against the initial stop ${fmtNum(r.sl_initial)}, not the current one.`
      : 'No initial stop recorded, so no R multiple.',
    r.state === 'PARTIAL'
      ? 'Assumes full size since entry: profits the trader booked are NOT modelled.'
      : 'Assumes full size since entry.',
  ].filter(Boolean).join(' ');
  return (
    <span title={tip} style={{ color }}>
      {signedPct(p.pnl_pct)}
      {p.pnl_r != null && (
        <span style={{ marginLeft: 7, fontSize: 10.5, opacity: 0.85 }}>{signedR(p.pnl_r)}</span>
      )}
    </span>
  );
}

// ── manual close: an operator safety valve, not the trader's stop/target ──
function ConfirmCloseModal({ target, onCancel, onConfirm, busy, error }) {
  if (!target) return null;
  const { row, exch, accountLabel } = target;
  return (
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 50,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
    }} onClick={busy ? undefined : onCancel}>
      <div onClick={e => e.stopPropagation()} className="card" style={{ width: 380, maxWidth: '90vw' }}>
        <div className="card-h"><span style={{ fontWeight: 600, fontSize: 13, color: 'var(--bear)' }}>⚠ Close position now</span></div>
        <div className="card-b" style={{ fontSize: 12.5, lineHeight: 1.6 }}>
          <div style={{ marginBottom: 10 }}>
            This sends a real reduce-only market order on <b>{row.symbol}</b> ({row.side})
            {accountLabel && <> on account <b>{accountLabel}</b></>} and flattens it immediately —
            ahead of the trader's own stop or target. This cannot be undone.
          </div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '9px 11px', background: 'var(--bg-elev)', borderRadius: 7, marginBottom: 12 }}>
            <div style={{ display: 'flex', justifyContent: 'space-between' }}>
              <span style={{ color: 'var(--text-4)' }}>Contracts</span>
              <span className="mono">{fmtNum(exch && exch.contracts)}</span>
            </div>
            <div style={{ display: 'flex', justifyContent: 'space-between' }}>
              <span style={{ color: 'var(--text-4)' }}>Entry</span>
              <span className="mono">{fmtNum(exch && exch.entry_price)}</span>
            </div>
            <div style={{ display: 'flex', justifyContent: 'space-between' }}>
              <span style={{ color: 'var(--text-4)' }}>Unrealized P&amp;L</span>
              <span className="mono" style={{ color: Number(exch && exch.unrealized_pnl) >= 0 ? 'var(--bull)' : 'var(--bear)' }}>
                {exch && exch.unrealized_pnl != null ? fmtUsd(exch.unrealized_pnl) : '—'}
              </span>
            </div>
          </div>
          {error && <div style={{ color: 'var(--bear)', fontSize: 11.5, marginBottom: 10 }}>{error}</div>}
          <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
            <button onClick={onCancel} disabled={busy} style={{
              padding: '8px 14px', borderRadius: 7, cursor: busy ? 'wait' : 'pointer', fontSize: 12,
              background: 'transparent', border: '1px solid var(--border-soft)', color: 'var(--text-2)',
              fontFamily: 'var(--font-sans)',
            }}>Cancel</button>
            <button onClick={onConfirm} disabled={busy} style={{
              padding: '8px 14px', borderRadius: 7, cursor: busy ? 'wait' : 'pointer', fontSize: 12,
              background: 'var(--bear)', border: 'none', color: '#fff', fontWeight: 600,
              fontFamily: 'var(--font-sans)', opacity: busy ? 0.6 : 1,
            }}>{busy ? 'Closing…' : 'Close position'}</button>
          </div>
        </div>
      </div>
    </div>
  );
}

// ── resend a ledger-only signal ─────────────────────────────────────────────
// The signal was valid but no account held a position for it (no room, a key
// was down, ...). The server re-checks it's still in range before sending
// anything — this is just the confirm step and the refusal/result display.
function ConfirmResendModal({ target, onCancel, onConfirm, busy, error, result }) {
  if (!target) return null;
  const { row } = target;
  return (
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 50,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
    }} onClick={busy ? undefined : onCancel}>
      <div onClick={e => e.stopPropagation()} className="card" style={{ width: 400, maxWidth: '90vw' }}>
        <div className="card-h"><span style={{ fontWeight: 600, fontSize: 13, color: 'var(--gold)' }}>Resend to your account</span></div>
        <div className="card-b" style={{ fontSize: 12.5, lineHeight: 1.6 }}>
          <div style={{ marginBottom: 10 }}>
            Re-checks <b>{row.symbol}</b> ({row.side}) against the current price — refuses if it's
            drifted out of tolerance, already hit stop or target, or your account has no room —
            and only places a real order if your account is live and it's still a valid entry.
          </div>
          {error && (
            <div style={{ color: 'var(--bear)', fontSize: 11.5, marginBottom: 10, padding: '8px 10px', background: 'var(--bg-elev)', borderRadius: 7 }}>
              {error}
            </div>
          )}
          {result && result.ok && (
            <div style={{ color: 'var(--bull)', fontSize: 11.5, marginBottom: 10, padding: '8px 10px', background: 'var(--bg-elev)', borderRadius: 7 }}>
              Sent — {result.qty != null ? `${fmtNum(result.qty)} @ ${fmtNum(result.avg_fill)}` : (result.sizing || 'order placed')}.
            </div>
          )}
          <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
            <button onClick={onCancel} disabled={busy} style={{
              padding: '8px 14px', borderRadius: 7, cursor: busy ? 'wait' : 'pointer', fontSize: 12,
              background: 'transparent', border: '1px solid var(--border-soft)', color: 'var(--text-2)',
              fontFamily: 'var(--font-sans)',
            }}>{result && result.ok ? 'Close' : 'Cancel'}</button>
            {!(result && result.ok) && (
              <button onClick={onConfirm} disabled={busy} style={{
                padding: '8px 14px', borderRadius: 7, cursor: busy ? 'wait' : 'pointer', fontSize: 12,
                background: 'var(--gold)', border: 'none', color: 'var(--bg-base)', fontWeight: 600,
                fontFamily: 'var(--font-sans)', opacity: busy ? 0.6 : 1,
              }}>{busy ? 'Sending…' : 'Resend'}</button>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

// Operator repair for a signal whose chart-only SL/TP got dropped by a bug
// since fixed — re-reads the founding message with today's extraction and
// fills whatever's still missing, never overwriting a value already on
// record. See reanalyze_levels' own docstring (ai/src/signal_worker.py) for
// the $GRASS case that motivated this.
function ConfirmReanalyzeModal({ target, onCancel, onConfirm, busy, error, result }) {
  if (!target) return null;
  const { row } = target;
  return (
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 50,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
    }} onClick={busy ? undefined : onCancel}>
      <div onClick={e => e.stopPropagation()} className="card" style={{ width: 420, maxWidth: '90vw' }}>
        <div className="card-h"><span style={{ fontWeight: 600, fontSize: 13, color: 'var(--gold)' }}>Re-analyze SL/TP/entry</span></div>
        <div className="card-b" style={{ fontSize: 12.5, lineHeight: 1.6 }}>
          <div style={{ marginBottom: 10 }}>
            Re-reads the original message for <b>{row.symbol}</b> ({row.side}) — text and
            chart alike — with today's extraction, and fills in SL, targets, or entry price
            only where this signal still has none on record. Never overwrites a value
            that's already there, and never touches state, side, or any position.
          </div>
          {error && (
            <div style={{ color: 'var(--bear)', fontSize: 11.5, marginBottom: 10, padding: '8px 10px', background: 'var(--bg-elev)', borderRadius: 7 }}>
              {error}
            </div>
          )}
          {result && result.ok && (
            <div style={{ color: result.changed ? 'var(--bull)' : 'var(--text-3)', fontSize: 11.5, marginBottom: 10, padding: '8px 10px', background: 'var(--bg-elev)', borderRadius: 7 }}>
              {result.changed ? `Updated: ${result.fields.join(', ')}.` : (result.detail || 'Nothing missing was found.')}
            </div>
          )}
          <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
            <button onClick={onCancel} disabled={busy} style={{
              padding: '8px 14px', borderRadius: 7, cursor: busy ? 'wait' : 'pointer', fontSize: 12,
              background: 'transparent', border: '1px solid var(--border-soft)', color: 'var(--text-2)',
              fontFamily: 'var(--font-sans)',
            }}>{result && result.ok ? 'Close' : 'Cancel'}</button>
            {!(result && result.ok) && (
              <button onClick={onConfirm} disabled={busy} style={{
                padding: '8px 14px', borderRadius: 7, cursor: busy ? 'wait' : 'pointer', fontSize: 12,
                background: 'var(--gold)', border: 'none', color: 'var(--bg-base)', fontWeight: 600,
                fontFamily: 'var(--font-sans)', opacity: busy ? 0.6 : 1,
              }}>{busy ? 'Re-analyzing…' : 'Re-analyze'}</button>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

// ── edit a local-only entry (no real position yet) ─────────────────────────
// Symbol typos, a bad number — fix it here, then either leave it as a corrected
// paper record or, only for the live-armed venue, resend it for real.
function EditSignalModal({ target, onClose, onSaved, viewMode }) {
  const [form, setForm] = useS(null);
  const [busy, setBusy] = useS(false);
  const [err, setErr] = useS(null);
  const [confirmAction, setConfirmAction] = useS(null); // 'cancel' | 'close' | null
  const [closeForm, setCloseForm] = useS({ price: '', at: '' });

  useE(() => {
    if (!target) { setForm(null); setConfirmAction(null); return; }
    setCloseForm({ price: '', at: '' });
    setForm({
      symbol: target.symbol || '', side: target.side || 'long',
      entry_price: target.entry_price != null ? String(target.entry_price) : '',
      sl: target.sl != null ? String(target.sl) : '',
      tp: (target.tps && target.tps[0] != null) ? String(target.tps[0]) : '',
    });
    setErr(null);
  }, [target]);

  if (!target || !form) return null;

  const hasRealPos = (target.positions || []).some(p =>
    p.state && !['SKIPPED', 'CANCELLED'].includes(p.state));

  const num = (v) => { const n = Number(v); return Number.isFinite(n) && v !== '' ? n : undefined; };
  const buildChanges = () => ({
    symbol: form.symbol.trim().toUpperCase(),
    side: form.side,
    entry_price: num(form.entry_price),
    sl: num(form.sl),
    tps: num(form.tp) !== undefined ? [num(form.tp)] : undefined,
  });
  const buildPatchBody = () => {
    const body = {};
    if (form.side !== target.side) body.side = form.side;
    const ep = num(form.entry_price);
    if (ep !== undefined && ep !== Number(target.entry_price)) body.entry_price = ep;
    const sl = num(form.sl);
    if (sl !== undefined && sl !== target.sl) body.sl = sl;
    else if (form.sl === '' && target.sl != null) body.sl = null;
    const tp = num(form.tp);
    const oldTp = (target.tps && target.tps[0]) || null;
    if (tp !== undefined && tp !== oldTp) body.tps = [tp];
    else if (form.tp === '' && oldTp != null) body.tps = null;
    return body;
  };

  async function post(path, body) {
    return fetch(path, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: body ? JSON.stringify(body) : undefined,
    }).then(r => r.json()).catch(e => ({ ok: false, error: String(e.message || e) }));
  }

  function userAccountId() {
    const pos = (target.positions || [])[0];
    return pos ? pos.account_id : 0;
  }

  async function save(alsoResend) {
    setBusy(true); setErr(null);
    if (hasRealPos) {
      const body = buildPatchBody();
      if (!Object.keys(body).length) { setErr('No changes detected.'); setBusy(false); return; }
      const r = await fetch(`/api/signals/${target.id}`, {
        method: 'PATCH', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      }).then(x => x.json()).catch(e => ({ ok: false, error: String(e.message || e) }));
      setBusy(false);
      if (!r.ok) { setErr(r.detail ? `${r.error}: ${r.detail}` : (r.error || 'save failed')); return; }
      onSaved(); onClose();
      return;
    }
    const editUrl = viewMode === 'admin'
      ? `/api/signals/${target.id}/edit`
      : `/api/me/accounts/${userAccountId()}/signals/${target.id}/edit`;
    const r = await post(editUrl, { changes: buildChanges() });
    if (!r.ok) { setErr(r.detail ? `${r.reason}: ${r.detail}` : (r.reason || r.error || 'edit failed')); setBusy(false); return; }
    if (!alsoResend) { setBusy(false); onSaved(); onClose(); return; }
    const resendUrl = viewMode === 'admin'
      ? `/api/signals/${target.id}/resend`
      : `/api/me/accounts/${userAccountId()}/signals/${target.id}/resend`;
    const r2 = await post(resendUrl);
    setBusy(false);
    if (!r2.ok) { setErr(r2.detail ? `${r2.reason}: ${r2.detail}` : (r2.reason || r2.error || 'resend failed')); return; }
    onSaved(); onClose();
  }

  async function doTerminal(action) {
    setBusy(true); setErr(null);
    const url = viewMode === 'admin'
      ? `/api/signals/${target.id}/${action}`
      : `/api/me/accounts/${userAccountId()}/signals/${target.id}/${action}`;
    let body;
    if (action === 'close') {
      const price = closeForm.price.trim() !== '' ? Number(closeForm.price) : null;
      if (price !== null && !Number.isFinite(price)) { setBusy(false); setErr('Close price must be a number'); return; }
      // <input type="datetime-local"> has no timezone — it's local wall-clock time,
      // so this hands the backend that exact local instant as an ISO string with an
      // explicit offset; ledger_edit.close_signal parses it the same as any other
      // ISO timestamp and still refuses anything that lands in the future.
      const at = closeForm.at
        ? new Date(closeForm.at).toISOString()
        : null;
      body = { close_price: price, closed_at: at };
    }
    const r = await post(url, body);
    setBusy(false);
    if (!r.ok) { setErr(r.detail ? `${r.reason}: ${r.detail}` : (r.reason || r.error || `${action} failed`)); return; }
    onSaved(); onClose();
  }

  const inp = {
    padding: '7px 9px', borderRadius: 6, fontSize: 12.5, width: '100%',
    background: 'var(--bg-elev)', border: '1px solid var(--border-soft)',
    color: 'var(--text)', fontFamily: 'var(--font-mono)', boxSizing: 'border-box',
  };
  const label = { fontSize: 9.5, color: 'var(--text-4)', letterSpacing: '0.08em',
                  textTransform: 'uppercase', marginBottom: 4, display: 'block' };

  return (
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 50,
      display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
    }} onClick={() => !busy && onClose()}>
      <div onClick={e => e.stopPropagation()} className="card" style={{ width: 420, maxWidth: '100%' }}>
        <div className="card-h" style={{ justifyContent: 'space-between' }}>
          <span style={{ fontWeight: 600, fontSize: 13 }}>{hasRealPos ? 'Fix signal record' : 'Edit ledger entry'}</span>
          <button onClick={onClose} disabled={busy} style={{
            background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-4)', fontSize: 16, padding: 0,
          }}>×</button>
        </div>
        <div className="card-b">
          <div style={{ fontSize: 11.5, color: 'var(--text-3)', marginBottom: 14, lineHeight: 1.5 }}>
            {hasRealPos
              ? <>This signal has a real exchange position. Changes here fix the <b>ledger record</b> only — the exchange position is not touched.</>
              : <>No exchange position is held — safe to fix. Saving updates the ledger only;
                "Save &amp; resend" also re-checks entry against the current price.</>}
          </div>
          <div style={{ display: 'grid', gridTemplateColumns: hasRealPos ? '1fr 100px' : '1fr 100px', gap: 10, marginBottom: 10 }}>
            {!hasRealPos && <div>
              <span style={label}>Symbol</span>
              <input style={inp} value={form.symbol}
                     onChange={e => setForm({ ...form, symbol: e.target.value })} />
            </div>}
            {hasRealPos && <div>
              <span style={label}>Symbol</span>
              <input style={{ ...inp, opacity: 0.5 }} value={form.symbol} disabled title="Cannot change symbol on a signal with a real position" />
            </div>}
            <div>
              <span style={label}>Side</span>
              <select style={inp} value={form.side} onChange={e => setForm({ ...form, side: e.target.value })}>
                <option value="long">long</option>
                <option value="short">short</option>
              </select>
            </div>
          </div>
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10, marginBottom: 14 }}>
            <div>
              <span style={label}>Entry</span>
              <input style={inp} inputMode="decimal" value={form.entry_price}
                     onChange={e => setForm({ ...form, entry_price: e.target.value })} />
            </div>
            <div>
              <span style={label}>Stop</span>
              <input style={inp} inputMode="decimal" value={form.sl}
                     onChange={e => setForm({ ...form, sl: e.target.value })} />
            </div>
            <div>
              <span style={label}>Target</span>
              <input style={inp} inputMode="decimal" value={form.tp}
                     onChange={e => setForm({ ...form, tp: e.target.value })} />
            </div>
          </div>
          {err && <div style={{ color: 'var(--bear)', fontSize: 11.5, marginBottom: 12 }}>{err}</div>}
          <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginBottom: confirmAction ? 10 : 0 }}>
            {!hasRealPos && <button onClick={() => setConfirmAction('close')} disabled={busy} title="Mark this as a closed trade — it ran its course, e.g. you're done with it and want to open the other side" style={{
              padding: '8px 12px', borderRadius: 7, cursor: busy ? 'wait' : 'pointer', fontSize: 11.5,
              background: 'transparent', border: '1px solid var(--border-soft)', color: 'var(--text-3)',
              fontFamily: 'var(--font-sans)', marginRight: 'auto',
            }}>Close signal</button>}
            {!hasRealPos && <button onClick={() => setConfirmAction('cancel')} disabled={busy} title="Dismiss — this shouldn't have been added (typo, duplicate, bad extraction)" style={{
              padding: '8px 12px', borderRadius: 7, cursor: busy ? 'wait' : 'pointer', fontSize: 11.5,
              background: 'transparent', border: '1px solid var(--border-soft)', color: 'var(--text-3)',
              fontFamily: 'var(--font-sans)',
            }}>Cancel signal</button>}
            <button onClick={() => save(false)} disabled={busy} style={{
              padding: '8px 14px', borderRadius: 7, cursor: busy ? 'wait' : 'pointer', fontSize: 12,
              background: hasRealPos ? 'var(--gold)' : 'var(--bg-elev)',
              border: hasRealPos ? 'none' : '1px solid var(--border-soft)',
              color: hasRealPos ? 'var(--bg-base)' : 'var(--text-2)',
              fontWeight: hasRealPos ? 600 : 400,
              fontFamily: 'var(--font-sans)', opacity: busy ? 0.6 : 1,
            }}>{busy ? '…' : 'Save'}</button>
            {!hasRealPos && <button onClick={() => save(true)} disabled={busy} style={{
              padding: '8px 14px', borderRadius: 7, cursor: busy ? 'wait' : 'pointer', fontSize: 12,
              background: 'var(--gold)', border: 'none', color: 'var(--bg-base)', fontWeight: 600,
              fontFamily: 'var(--font-sans)', opacity: busy ? 0.6 : 1,
            }}>{busy ? '…' : 'Save & resend'}</button>}
          </div>
          {confirmAction === 'close' && (
            <div style={{ padding: '9px 11px', background: 'var(--bg-elev)', borderRadius: 7, display: 'flex', flexDirection: 'column', gap: 10 }}>
              <span style={{ fontSize: 11.5, color: 'var(--text-2)' }}>Mark this trade as closed and move it to history?</span>
              <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
                <div>
                  <span style={label}>Close price (optional)</span>
                  <input style={inp} inputMode="decimal" placeholder="—" value={closeForm.price}
                         onChange={e => setCloseForm(f => ({ ...f, price: e.target.value }))} />
                </div>
                <div>
                  <span style={label}>Closed at (optional)</span>
                  <input style={inp} type="datetime-local" value={closeForm.at}
                         max={new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16)}
                         onChange={e => setCloseForm(f => ({ ...f, at: e.target.value }))} />
                </div>
              </div>
              <div style={{ fontSize: 10.5, color: 'var(--text-4)' }}>
                Leave either blank to skip the price or use right now as the close time.
              </div>
              <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
                <button onClick={() => setConfirmAction(null)} disabled={busy} style={{
                  padding: '5px 10px', borderRadius: 6, cursor: 'pointer', fontSize: 11,
                  background: 'transparent', border: '1px solid var(--border-soft)', color: 'var(--text-3)',
                  fontFamily: 'var(--font-sans)',
                }}>No</button>
                <button onClick={() => doTerminal('close')} disabled={busy} style={{
                  padding: '5px 10px', borderRadius: 6, cursor: 'pointer', fontSize: 11, fontWeight: 600,
                  background: 'var(--bull)', border: 'none', color: '#fff', fontFamily: 'var(--font-sans)',
                }}>Yes, close it</button>
              </div>
            </div>
          )}
          {confirmAction === 'cancel' && (
            <div style={{ padding: '9px 11px', background: 'var(--bg-elev)', borderRadius: 7, display: 'flex', alignItems: 'center', gap: 10 }}>
              <span style={{ fontSize: 11.5, color: 'var(--text-2)', flex: 1 }}>Remove this from the ledger entirely?</span>
              <button onClick={() => setConfirmAction(null)} disabled={busy} style={{
                padding: '5px 10px', borderRadius: 6, cursor: 'pointer', fontSize: 11,
                background: 'transparent', border: '1px solid var(--border-soft)', color: 'var(--text-3)',
                fontFamily: 'var(--font-sans)',
              }}>No</button>
              <button onClick={() => doTerminal('cancel')} disabled={busy} style={{
                padding: '5px 10px', borderRadius: 6, cursor: 'pointer', fontSize: 11, fontWeight: 600,
                background: 'var(--bear)', border: 'none', color: '#fff', fontFamily: 'var(--font-sans)',
              }}>Yes, remove it</button>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

function CreateSignalModal({ target, onClose, onSaved }) {
  const [form, setForm] = useS({});
  const [busy, setBusy] = useS(false);
  const [result, setResult] = useS(null);

  useE(() => {
    if (!target) return;
    const ex = target.extraction || {};
    setForm({
      symbol: (ex.symbol || target.symbol || '').toUpperCase(),
      side: ex.side || target.side || '',
      entry_price: String(ex.entry_price ?? ex.entry_reference ?? ''),
      sl: String(ex.sl ?? ''),
      tps: (ex.tps || []).join(', '),
      entry_type: ex.entry_type || 'cmp',
    });
    setResult(null);
  }, [target]);

  if (!target) return null;

  const upd = (k, v) => setForm(f => ({ ...f, [k]: v }));

  async function submit() {
    if (!form.symbol.trim() || !form.side) return setResult({ ok: false, error: 'Symbol and side are required' });
    setBusy(true); setResult(null);
    const tpsArr = form.tps.trim()
      ? form.tps.split(',').map(s => Number(s.trim())).filter(n => Number.isFinite(n))
      : [];
    const body = {
      sub_event_id: target.id,
      symbol: form.symbol.trim().toUpperCase(),
      side: form.side,
      entry_price: form.entry_price !== '' ? Number(form.entry_price) : null,
      sl: form.sl !== '' ? Number(form.sl) : null,
      tps: tpsArr.length ? tpsArr : null,
      entry_type: form.entry_type || null,
    };
    const r = await fetch('/api/signals/create-manual', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    }).then(x => x.json()).catch(e => ({ ok: false, error: String(e.message || e) }));
    setBusy(false);
    setResult(r);
    if (r.ok) setTimeout(() => { onSaved(r.signal_id); onClose(); }, 600);
  }

  const inp = { background: 'var(--bg-input)', border: '1px solid var(--border-soft)',
    borderRadius: 6, padding: '6px 10px', color: 'var(--text)', fontSize: 13,
    fontFamily: 'var(--font-mono)', width: '100%', boxSizing: 'border-box' };
  const lbl = { fontSize: 11, color: 'var(--text-3)', marginBottom: 2, display: 'block' };

  return (
    <div onClick={onClose} style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,.55)',
      display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 900,
    }}>
      <div onClick={e => e.stopPropagation()} className="card" style={{
        width: 420, maxWidth: '95vw', padding: 0,
      }}>
        <div className="card-h" style={{ padding: '12px 16px' }}>Add to ledger</div>
        <div className="card-b" style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 100px', gap: 8 }}>
            <div>
              <label style={lbl}>Symbol</label>
              <input value={form.symbol || ''} onChange={e => upd('symbol', e.target.value)} style={inp} />
            </div>
            <div>
              <label style={lbl}>Side</label>
              <select value={form.side || ''} onChange={e => upd('side', e.target.value)}
                style={{ ...inp, fontFamily: 'var(--font-sans)' }}>
                <option value="">—</option>
                <option value="long">Long</option>
                <option value="short">Short</option>
              </select>
            </div>
          </div>
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
            <div>
              <label style={lbl}>Entry price</label>
              <input value={form.entry_price || ''} onChange={e => upd('entry_price', e.target.value)} style={inp} />
            </div>
            <div>
              <label style={lbl}>Stop loss</label>
              <input value={form.sl || ''} onChange={e => upd('sl', e.target.value)} style={inp} />
            </div>
            <div>
              <label style={lbl}>Take profits</label>
              <input value={form.tps || ''} onChange={e => upd('tps', e.target.value)} style={inp}
                placeholder="comma-separated" />
            </div>
          </div>
          <div style={{ fontSize: 10.5, color: 'var(--text-4)' }}>
            Creates a new LIVE signal on the ledger, independent of whatever this message's
            own gate decision was — edit any field before saving. Timestamp matches the message.
          </div>
          {result && (
            <div style={{ fontSize: 12, color: result.ok ? 'var(--bull)' : 'var(--bear)' }}>
              {result.ok ? 'Added to ledger (signal #' + result.signal_id + ')' : result.error}
            </div>
          )}
          <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
            <button onClick={onClose} className="btn ghost" style={{ fontSize: 12 }}>Cancel</button>
            <button onClick={submit} disabled={busy} className="btn gold" style={{ fontSize: 12 }}>
              {busy ? 'Adding…' : 'Add to ledger'}
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

function EditPositionModal({ target, onClose, onSaved, viewMode }) {
  const [form, setForm] = useS(null);
  const [busy, setBusy] = useS(false);
  const [err, setErr] = useS(null);
  const [result, setResult] = useS(null);

  useE(() => {
    if (!target) { setForm(null); setResult(null); return; }
    const tps = target.pos.our_tps;
    setForm({
      sl: target.pos.our_sl != null ? String(target.pos.our_sl) : '',
      tp: (tps && tps[0] != null) ? String(tps[0]) : '',
      size: target.pos.filled_qty != null ? String(target.pos.filled_qty) : '',
    });
    setErr(null); setResult(null);
  }, [target]);

  if (!target || !form) return null;

  const pos = target.pos;
  const row = target.row;
  const num = (v) => { const n = Number(v); return Number.isFinite(n) && v !== '' ? n : undefined; };

  function buildChanges() {
    const c = {};
    const newSl = num(form.sl), newTp = num(form.tp), newSize = num(form.size);
    if (newSl !== undefined && newSl !== pos.our_sl) c.sl = newSl;
    if (newTp !== undefined) {
      const oldTp = (pos.our_tps && pos.our_tps[0]) || null;
      if (newTp !== oldTp) c.tp = newTp;
    }
    if (newSize !== undefined && newSize !== Number(pos.filled_qty)) c.size = newSize;
    return c;
  }

  function changeSummary() {
    const c = buildChanges();
    const lines = [];
    if (c.sl !== undefined) lines.push(`SL: ${fmtNum(pos.our_sl)} → ${fmtNum(c.sl)}`);
    if (c.tp !== undefined) {
      const oldTp = (pos.our_tps && pos.our_tps[0]) || null;
      lines.push(`TP: ${fmtNum(oldTp)} → ${fmtNum(c.tp)}`);
    }
    if (c.size !== undefined) lines.push(`Size: ${fmtNum(pos.filled_qty)} → ${fmtNum(c.size)}`);
    return lines;
  }

  async function submit() {
    const changes = buildChanges();
    if (!Object.keys(changes).length) { setErr('No changes to apply'); return; }
    if (changes.size !== undefined && changes.size > Number(pos.filled_qty)) {
      setErr('Cannot increase size — reduce only'); return;
    }
    setBusy(true); setErr(null); setResult(null);
    try {
      let url;
      if (viewMode === 'admin') {
        url = `/api/signals/${row.id}/edit-position`;
      } else {
        url = `/api/me/accounts/${pos.account_id}/positions/${pos.id}/edit`;
      }
      const body = viewMode === 'admin'
        ? { changes, account_id: pos.account_id }
        : { changes };
      const r = await fetch(url, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      }).then(x => x.json());
      setBusy(false);
      if (!r.ok) {
        const detail = (r.edits || []).filter(e => !e.ok).map(e => `${e.field}: ${e.reason || e.detail}`).join('; ');
        setErr(detail || r.reason || r.error || 'Edit failed');
        return;
      }
      setResult(r);
      onSaved();
    } catch (e) {
      setErr(String(e.message || e)); setBusy(false);
    }
  }

  const inp = {
    padding: '7px 9px', borderRadius: 6, fontSize: 12.5, width: '100%',
    background: 'var(--bg-elev)', border: '1px solid var(--border-soft)',
    color: 'var(--text)', fontFamily: 'var(--font-mono)', boxSizing: 'border-box',
  };
  const label = { fontSize: 9.5, color: 'var(--text-4)', letterSpacing: '0.08em',
                  textTransform: 'uppercase', marginBottom: 4, display: 'block' };
  const summary = changeSummary();

  return (
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 50,
      display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
    }} onClick={() => !busy && onClose()}>
      <div onClick={e => e.stopPropagation()} className="card" style={{ width: 420, maxWidth: '100%' }}>
        <div className="card-h" style={{ justifyContent: 'space-between' }}>
          <span style={{ fontWeight: 600, fontSize: 13 }}>
            Edit position · {row.symbol} {row.side}
            {pos.account_label ? ` · ${pos.account_label}` : ''}
          </span>
          <button onClick={onClose} disabled={busy} style={{
            background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-4)', fontSize: 16, padding: 0,
          }}>×</button>
        </div>
        <div className="card-b">
          {!result && (
            <>
              <div style={{ fontSize: 11.5, color: 'var(--text-3)', marginBottom: 14, lineHeight: 1.5 }}>
                Changes are sent to the exchange immediately. Size can only be reduced (partial close).
              </div>
              <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10, marginBottom: 14 }}>
                <div>
                  <span style={label}>Stop Loss</span>
                  <input style={inp} inputMode="decimal" value={form.sl}
                         onChange={e => setForm({ ...form, sl: e.target.value })} />
                </div>
                <div>
                  <span style={label}>Take Profit</span>
                  {(pos.our_tps || []).length > 1 ? (
                    <div style={{ ...inp, minHeight: 34, display: 'flex', flexDirection: 'column', gap: 2, color: 'var(--text-3)' }}>
                      {(pos.our_tps || []).map((tp, i) => (
                        <span key={i} style={{ fontFamily: 'var(--font-mono)', fontSize: 11.5 }}>TP{i + 1}: {fmtNum(tp)}</span>
                      ))}
                      <span style={{ fontSize: 9.5, color: 'var(--text-4)', marginTop: 2 }}>Multi-TP — edit not supported</span>
                    </div>
                  ) : (
                    <input style={inp} inputMode="decimal" value={form.tp}
                           onChange={e => setForm({ ...form, tp: e.target.value })} />
                  )}
                </div>
                <div>
                  <span style={label}>Size (qty)</span>
                  <input style={inp} inputMode="decimal" value={form.size}
                         onChange={e => setForm({ ...form, size: e.target.value })} />
                </div>
              </div>
              {summary.length > 0 && (
                <div style={{ padding: '8px 11px', background: 'var(--bg-elev)', borderRadius: 7, marginBottom: 12, fontSize: 11.5, lineHeight: 1.6 }}>
                  {summary.map((line, i) => <div key={i} style={{ color: 'var(--text-2)' }}>{line}</div>)}
                </div>
              )}
            </>
          )}
          {result && (
            <div style={{ padding: '10px 12px', background: 'var(--bg-elev)', borderRadius: 7, marginBottom: 12 }}>
              <div style={{ color: 'var(--bull)', fontSize: 12, fontWeight: 600, marginBottom: 6 }}>Position updated</div>
              {(result.edits || []).map((e, i) => (
                <div key={i} style={{ fontSize: 11.5, color: e.ok ? 'var(--text-2)' : 'var(--bear)', marginBottom: 3 }}>
                  {e.field}: {fmtNum(e.old)} → {fmtNum(e.new)} {e.ok ? '✓' : `✗ ${e.reason || ''}`}
                </div>
              ))}
            </div>
          )}
          {err && <div style={{ color: 'var(--bear)', fontSize: 11.5, marginBottom: 12 }}>{err}</div>}
          <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
            <button onClick={onClose} disabled={busy} style={{
              padding: '8px 14px', borderRadius: 7, cursor: busy ? 'wait' : 'pointer', fontSize: 12,
              background: 'transparent', border: '1px solid var(--border-soft)', color: 'var(--text-2)',
              fontFamily: 'var(--font-sans)',
            }}>{result ? 'Close' : 'Cancel'}</button>
            {!result && (
              <button onClick={submit} disabled={busy || !summary.length} style={{
                padding: '8px 14px', borderRadius: 7, cursor: busy ? 'wait' : 'pointer', fontSize: 12,
                background: 'var(--gold)', border: 'none', color: 'var(--bg-base)', fontWeight: 600,
                fontFamily: 'var(--font-sans)', opacity: (busy || !summary.length) ? 0.5 : 1,
              }}>{busy ? 'Applying…' : 'Apply changes'}</button>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

// A sticky <thead> was tried here (2026-10-06) and reverted the same day: the
// table's horizontally-scrolling wrapper (overflowX: 'auto' below) implicitly
// computes overflow-y: auto too (CSS couples the two — one axis can't be
// non-visible while the other stays visible), which makes that div the
// sticky positioning's containing block. Since the div itself never actually
// scrolls vertically (its height just grows to fit content), position:
// sticky on a descendant never engages at all — confirmed with precise
// scroll-delta measurements showing the header moving in perfect lockstep
// with scroll, on both mobile and desktop widths, never catching. A correct
// fix needs a second, horizontally-synced header rendered outside that
// scrolling div, not attempted here.

// ── ledger vs the following accounts' positions ────────────────────────────
const POS_OPEN = ['ENTRY_WORKING', 'OPEN', 'PARTIAL'];
const POS_STATE_LABEL = {
  ENTRY_WORKING: 'entering', OPEN: 'open', PARTIAL: 'partly closed',
  CLOSED: 'closed', CANCELLED: 'cancelled', EXPIRED: 'expired', SKIPPED: 'skipped',
  AWAITING_APPROVAL: 'awaiting approval',
};

// Shared by LedgerTable and StrategyPnlRow, computed once in the parent, so the
// table and the widgets above it are always looking at the identical filtered set
// — never two independent filter passes quietly drifting apart. Applies the
// account-env filter first (which positions even count), then decides per row
// whether anything real is left to show; matchedOnly drops rows where no account
// actually holds a position once that filter is applied.
// opts: sideFilter ('all'/'long'/'short'), driftFilter ('all'/'matched'/'drift'
// — matched means at least one open position survived the env filter above),
// accountFilter ('all' or an account_id, checked against the SAME env-filtered
// positions so the two compound correctly), openedSince/openedUntil (date
// strings, inclusive, against created_at — independent of period/customSince/
// customUntil, which are documented to never touch the open ledger).
function filterLedgerRows(rows, matchedOnly, envFilter, includeOrphans, opts = {}) {
  const { sideFilter = 'all', driftFilter = 'all', accountFilter = 'all',
         openedSince = '', openedUntil = '' } = opts;
  const sinceT = openedSince ? Date.parse(openedSince + 'T00:00:00Z') : null;
  const untilT = openedUntil ? Date.parse(openedUntil + 'T23:59:59Z') : null;
  return rows.map(r => {
    const positions = (r.positions || []).filter(p => envFilter === 'all' || p.account_env === envFilter);
    return { ...r, positions, open: positions.filter(p => POS_OPEN.includes(p.state)) };
  }).filter(r => {
    if (matchedOnly && !r.open.length) return false;
    if (includeOrphans && !r.open.length) return false;
    if (envFilter !== 'all' && !r.positions.length) return false;
    if (sideFilter !== 'all' && r.side !== sideFilter) return false;
    if (driftFilter === 'matched' && !r.open.length) return false;
    if (driftFilter === 'drift' && r.open.length) return false;
    if (accountFilter !== 'all' && !r.positions.some(p => String(p.account_id) === String(accountFilter))) return false;
    if (sinceT != null || untilT != null) {
      if (!r.created_at) return false;
      const t = Date.parse(String(r.created_at).endsWith('Z') ? r.created_at : r.created_at + 'Z');
      if (!Number.isFinite(t)) return false;
      if (sinceT != null && t < sinceT) return false;
      if (untilT != null && t > untilT) return false;
    }
    return true;
  });
}


// Small chip when SL/TP came from a manual path (UI edit or backfill). Raw source stays in title.
function ManualSourceChip({ source }) {
  if (!source || !/manual/i.test(String(source))) return null;
  const label = /backfill/i.test(String(source)) ? 'Manual backfill' : 'Manual';
  return (
    <span className="chip neutral"
          title={(String(source) || 'Manual') + ' — shown read-only on Strategy; edit via Operator → Edit record'}
          style={{ marginLeft: 6, fontSize: 9, fontFamily: 'var(--font-sans)', fontWeight: 600, verticalAlign: 'middle' }}>
      {label}
    </span>
  );
}

// ── Research Spec — per-coin trend confirm, own Binance data ───────────────
// Backed by research_trend_cache (Postgres), refreshed every 30 min by a cron
// job (scripts/research-trend-refresh.js) for whatever's currently in any
// ledger. GET /api/research/spec?symbol=X reads that cache and only falls
// back to a live (cooldown-limited) Binance fetch on a genuine cache miss —
// this never re-fetches on every click. Never invents a bias: Incomplete
// unless the cache has an actual computed trend.
const SPEC_CHECK_LABEL = { ema_stack: 'EMA stack', ema20_slope: 'EMA20 slope', rsi_agree: 'RSI agreement', macd_agree: 'MACD agreement' };
function specPhaseLabel(p) {
  if (!p) return null;
  if (p === 'v0_stub') return 'V0 stub';
  if (p === 'v1_trend') return 'V1 trend';
  if (p === 'v2_sector') return 'V2 sector';
  return String(p).replace(/_/g, ' ');
}
function specSourceLabel(s) { return !s ? null : s === 'binance' ? 'Binance' : s === 'stub' ? 'stub' : String(s); }
function specBiasChip(b) { return b === 'bullish' ? 'chip bull' : b === 'bearish' ? 'chip bear' : 'chip neutral'; }
function specFormatAsOf(a) {
  if (a == null || String(a).trim() === '') return null;
  const d = new Date(a);
  if (Number.isNaN(d.getTime())) return String(a);
  try {
    return d.toLocaleString('en-GB', {
      timeZone: 'Europe/London', year: 'numeric', month: 'short', day: '2-digit',
      hour: '2-digit', minute: '2-digit', hour12: false, timeZoneName: 'short',
    });
  } catch (e) { return String(a); }
}
const specLabelStyle = { fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--text-4)', marginBottom: 4 };

function SpecMetaStrip({ data }) {
  const symbol = data && data.symbol, asOfRaw = data && data.as_of, asOf = specFormatAsOf(asOfRaw);
  const pL = specPhaseLabel(data && data.phase), sL = specSourceLabel(data && data.source);
  const items = [];
  if (symbol != null && String(symbol).trim() !== '') items.push({ k: 'Symbol', v: String(symbol), mono: true });
  if (asOf) items.push({ k: 'As of', v: asOf, mono: true, title: asOfRaw ? String(asOfRaw) : undefined });
  if (pL) items.push({ k: 'Phase', v: pL });
  if (sL) items.push({ k: 'Source', v: sL });
  if (!items.length) return null;
  return (
    <div style={{
      display: 'flex', flexWrap: 'wrap', gap: '12px 18px', marginBottom: 12, fontSize: 12, color: 'var(--text-3)',
      padding: '8px 10px', borderRadius: 'var(--r-sm)', background: 'var(--bg-elev)', border: '1px solid var(--border-soft)',
    }}>
      {items.map(it => (
        <div key={it.k} title={it.title}>
          <div style={specLabelStyle}>{it.k}</div>
          <div className={it.mono ? 'mono' : undefined} style={{ color: 'var(--text-2)', fontSize: 12.5 }}>{it.v}</div>
        </div>
      ))}
    </div>
  );
}

function SpecCheckRow({ check, idx }) {
  const id = (check && check.id) || ('check-' + idx), pass = !!(check && check.pass), detail = (check && check.detail) || '';
  const isAgree = id === 'rsi_agree' || id === 'macd_agree';
  const cue = isAgree ? (pass ? 'Agrees' : 'No agree') : (pass ? 'Pass' : 'Fail');
  return (
    <div style={{
      display: 'grid', gridTemplateColumns: '88px 1fr', gap: 8, alignItems: 'start', padding: '5px 0',
      borderTop: idx === 0 ? 'none' : '1px solid var(--border-soft)', fontSize: 11.5, lineHeight: 1.45,
    }}>
      <div>
        <div className="mono" style={{ color: 'var(--text-2)', fontSize: 11, fontWeight: 500 }}>
          {(id && SPEC_CHECK_LABEL[id]) || String(id).replace(/_/g, ' ')}
        </div>
        <div style={{ marginTop: 2, fontSize: 10, letterSpacing: '0.04em', textTransform: 'uppercase', color: pass ? 'var(--bull)' : 'var(--text-4)', fontWeight: 600 }}>{cue}</div>
      </div>
      <div style={{ color: 'var(--text-3)' }}>
        {detail ? String(detail) : <span style={{ fontStyle: 'italic', color: 'var(--text-4)' }}>No detail</span>}
      </div>
    </div>
  );
}

function SpecTrendBlock({ trend }) {
  if (!trend || typeof trend !== 'object') return <div style={{ color: 'var(--text-4)', fontStyle: 'italic', fontSize: 12 }}>No trend (fail-closed)</div>;
  const bias = trend.bias || 'neutral', checks = Array.isArray(trend.checks) ? trend.checks : [], tf = trend.timeframe ? String(trend.timeframe) : null;
  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
        <div>
          <div style={specLabelStyle}>Bias</div>
          <span className={specBiasChip(bias)} style={{ fontSize: 10 }}>{String(bias)}</span>
        </div>
        {tf && (
          <div>
            <div style={specLabelStyle}>Timeframe</div>
            <span className="chip neutral mono" style={{ fontSize: 10 }}>{tf}</span>
          </div>
        )}
      </div>
      {trend.summary && <div style={{ color: 'var(--text-2)', fontSize: 12.5, marginBottom: 10, lineHeight: 1.5 }}>{String(trend.summary)}</div>}
      {checks.length > 0 && (
        <div>
          <div style={{ ...specLabelStyle, marginBottom: 2 }}>Checks</div>
          <div style={{ fontSize: 10.5, color: 'var(--text-4)', marginBottom: 4, lineHeight: 1.4 }}>Stack/slope set bias; RSI/MACD agree only.</div>
          <div>{checks.map((c, i) => <SpecCheckRow key={(c && c.id) || ('check-' + i)} check={c} idx={i} />)}</div>
        </div>
      )}
    </div>
  );
}

function fetchSpec(symbol, setData, setErr, setLoading) {
  if (setLoading) setLoading(true);
  setErr(null);
  const fallback = { status: 'Incomplete', phase: 'v1_trend', reason: 'Spec request failed (Incomplete).', as_of: null, symbol, trend: null, sector_flow: null, source: 'binance' };
  return fetch('/api/research/spec?symbol=' + encodeURIComponent(symbol))
    .then(r => r.json())
    .then(j => {
      if (setLoading) setLoading(false);
      if (j && j.ok && j.data) { setData(j.data); setErr(null); }
      else { setData(fallback); setErr((j && j.error) || 'request failed'); }
    })
    .catch(e => {
      if (setLoading) setLoading(false);
      setData(fallback);
      setErr(String(e.message || e));
    });
}

function SpecModal({ symbol, onClose }) {
  const [data, setData] = useS(null);
  const [loading, setLoading] = useS(true);
  const [err, setErr] = useS(null);
  useE(() => { setData(null); setErr(null); fetchSpec(symbol, setData, setErr, setLoading); }, [symbol]);

  const status = (data && data.status) || 'Incomplete', isInc = status !== 'Ready';
  const reason = (data && data.reason) || (err ? `Spec load failed (${err}). Treating as Incomplete.` : 'Research Spec is not ready yet.');
  const pL = specPhaseLabel(data && data.phase), sL = specSourceLabel(data && data.source);
  const trendVal = (!isInc && data && data.trend != null) ? data.trend : null;
  const sectorVal = (!isInc && data && data.sector_flow != null) ? data.sector_flow : null;
  const dup = trendVal && trendVal.summary && String(reason) === String(trendVal.summary);
  const trendHint = (data && data.phase === 'v1_trend') ? 'No trend yet (fail-closed)' : 'Until V1';

  return (
    <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={onClose}>
      <div onClick={e => e.stopPropagation()} className="card" style={{ width: 520, maxWidth: '100%', maxHeight: '90vh', overflow: 'auto' }}>
        <div className="card-h" style={{ justifyContent: 'space-between' }}>
          <span style={{ fontWeight: 600, fontSize: 13 }}>Research Spec · {symbol}</span>
          <button onClick={onClose} aria-label="Close" style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-4)', fontSize: 16, padding: 0, lineHeight: 1 }}>×</button>
        </div>
        <div className="card-b" style={{ fontSize: 12.5, lineHeight: 1.6 }}>
          {loading ? <div style={{ color: 'var(--text-4)' }}>Loading Spec…</div> : (
            <>
              <div style={{
                fontSize: 11.5, color: 'var(--text-2)', marginBottom: 12, lineHeight: 1.5, padding: '9px 11px',
                background: 'var(--bg-elev)', borderRadius: 7, borderLeft: '3px solid ' + (isInc ? 'var(--text-4)' : 'var(--gold, #d4a017)'),
              }}>
                {isInc ? 'Not Ready — Incomplete until trend cache is fresh. Bias is never invented.'
                  : 'Spec Ready (V1 trend). Sector flow pending (V2). Own Binance data only.'}
              </div>
              <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8, marginBottom: 10 }}>
                <span className={'chip ' + (isInc ? 'neutral' : 'gold')} style={{ fontSize: 10 }} title={isInc ? 'Fail-closed: not Ready' : 'Spec Ready'}>{status}</span>
                {pL && <span className="chip neutral" style={{ fontSize: 10 }} title={data && data.phase}>{pL}</span>}
                {sL && <span className="chip neutral" style={{ fontSize: 10 }} title={'source: ' + (data && data.source)}>{sL}</span>}
              </div>
              {(isInc || !dup) && <p style={{ margin: '0 0 12px 0', color: isInc ? 'var(--text-2)' : 'var(--text-3)', fontSize: isInc ? 12.5 : 12 }}>{reason}</p>}
              <SpecMetaStrip data={data} />
              <div style={{ padding: 12, borderRadius: 'var(--r-sm)', marginBottom: 10, background: 'var(--bg-elev)', border: '1px solid var(--border-soft)' }}>
                <div style={specLabelStyle}>Trend</div>
                {trendVal ? <SpecTrendBlock trend={trendVal} /> : <div style={{ color: 'var(--text-4)', fontStyle: 'italic', fontSize: 12 }}>{trendHint}</div>}
              </div>
              <div style={{ padding: 12, borderRadius: 'var(--r-sm)', background: 'var(--bg-elev)', border: '1px solid var(--border-soft)' }}>
                <div style={specLabelStyle}>Sector flow</div>
                {sectorVal != null
                  ? <div className="mono" style={{ color: 'var(--text-2)', fontSize: 12.5 }}>{String(sectorVal)}</div>
                  : <div style={{ color: 'var(--text-4)', fontStyle: 'italic', fontSize: 12 }}>Until V2</div>}
              </div>
              <p style={{ margin: '12px 0 0 0', fontSize: 11, color: 'var(--text-4)' }}>Own Binance data only. No live trading.</p>
            </>
          )}
        </div>
      </div>
    </div>
  );
}

// Clickable symbol cell — opens SpecModal for that row's coin.
function SpecSymbolLink({ symbol, onOpenSpec, style }) {
  if (!symbol || !onOpenSpec) return <>{symbol}</>;
  return (
    <span onClick={() => onOpenSpec(symbol)} title={`Research Spec for ${symbol}`}
          style={{ cursor: 'pointer', textDecoration: 'underline', textDecorationStyle: 'dotted', textUnderlineOffset: 3, ...style }}>
      {symbol}
    </span>
  );
}

// A setup with no number to anchor to (market_cmp — "Entry: CMP", no DCA floor)
// has reference_price=null by construction (extract() never invents one — see
// signal_extract.py's extract_entry). That's indistinguishable in the raw data
// from "we don't know the entry" unless entry_type is read alongside it, so the
// sort key and the display label both check it the same way.
function ledgerEntryRefLabel(r) {
  if (r.reference_price != null) return fmtNum(r.reference_price);
  return r.entry_type === 'market_cmp' ? 'CMP (trader stated)' : '—';
}

const LEDGER_SORT_DEFAULT_DIR = {
  symbol: 'asc', side: 'asc', state: 'asc',
  opened: 'desc', sl: 'asc', entryRef: 'asc', paperPnl: 'desc',
  drift: 'desc', updated: 'desc',
};

function ledgerSortValue(row, key) {
  if (key === 'symbol') return (row.symbol || '').toLowerCase();
  if (key === 'side') return (row.side || '').toLowerCase();
  if (key === 'state') return (POS_STATE_LABEL[row.state] || row.state || '').toLowerCase();
  if (key === 'opened' || key === 'updated') {
    const iso = key === 'opened' ? row.created_at : row.updated_at;
    if (!iso) return null;
    const t = Date.parse(String(iso).endsWith('Z') ? iso : iso + 'Z');
    return Number.isFinite(t) ? t : null;
  }
  if (key === 'sl') {
    // Number(null) is 0 — same footgun fmtNum guards against; null must stay null.
    if (row.sl == null) return null;
    const n = Number(row.sl);
    return Number.isFinite(n) ? n : null;
  }
  if (key === 'entryRef') {
    if (row.reference_price == null) return null;
    const n = Number(row.reference_price);
    return Number.isFinite(n) ? n : null;
  }
  if (key === 'paperPnl') {
    const v = row.paper && row.paper.pnl_pct;
    if (v == null) return null;
    const n = Number(v);
    return Number.isFinite(n) ? n : null;
  }
  if (key === 'drift') return (row.open || []).length ? 0 : 1;
  return null;
}

function sortLedgerRows(rows, sortKey, sortDir) {
  const dir = sortDir === 'asc' ? 1 : -1;
  return [...rows].sort((a, b) => {
    const av = ledgerSortValue(a, sortKey);
    const bv = ledgerSortValue(b, sortKey);
    if (av == null && bv == null) return 0;
    if (av == null) return 1; // nulls last either way
    if (bv == null) return -1;
    if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
    return String(av).localeCompare(String(bv), 'en-GB') * dir;
  });
}

function LedgerTable({ rows, filtered, onRequestClose, onEditPosition, onEditLedgerLevels, onViewMessage, flashRow, onEdit, onResend, onReanalyze, onViewChart, viewMode, onOpenSpec, userRole, onAsk }) {
  const [sortKey, setSortKey] = useS('updated');
  const [sortDir, setSortDir] = useS('desc');
  const [expanded, setExpanded] = useS(() => new Set());
  const toggleExpand = (id) => setExpanded(prev => {
    const next = new Set(prev);
    next.has(id) ? next.delete(id) : next.add(id);
    return next;
  });

  if (rows == null) {
    return <div style={{ padding: '22px 0', textAlign: 'center', color: 'var(--text-4)', fontSize: 12 }}>
      Loading…
    </div>;
  }
  if (!filtered.length) {
    return <div style={{ padding: '22px 0', textAlign: 'center', color: 'var(--text-4)', fontSize: 12 }}>
      {rows.length ? 'No signals match the current filters.' : 'No open signals in the ledger.'}
    </div>;
  }
  const sorted = sortLedgerRows(filtered, sortKey, sortDir);

  const th = { textAlign: 'left', fontSize: 9.5, color: 'var(--text-4)', letterSpacing: '0.09em',
               textTransform: 'uppercase', padding: '0 8px 7px', fontWeight: 500 };
  const thSort = { ...th, cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' };
  const td = { padding: '7px 8px', borderTop: '1px solid var(--border-soft)', fontSize: 11.5 };

  const onSort = (key) => {
    if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
    else { setSortKey(key); setSortDir(LEDGER_SORT_DEFAULT_DIR[key] || 'asc'); }
  };
  const sortMark = (key) => {
    if (sortKey !== key) return <span style={{ opacity: 0.35, marginLeft: 3 }}>↕</span>;
    return <span style={{ color: 'var(--gold, #d4a017)', marginLeft: 3 }}>{sortDir === 'asc' ? '▲' : '▼'}</span>;
  };
  const SortTh = ({ col, children, title }) => (
    <th style={{ ...thSort, color: sortKey === col ? 'var(--text-2)' : th.color, fontWeight: sortKey === col ? 700 : 500 }}
        title={title} onClick={() => onSort(col)} aria-sort={sortKey === col ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}>
      {children}{sortMark(col)}
    </th>
  );

  return (
    <div style={{ overflowX: 'auto' }}>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead><tr>
          <th style={{ ...th, width: 20 }}></th>
          <SortTh col="symbol">symbol</SortTh><SortTh col="side">side</SortTh><SortTh col="state">state</SortTh>
          <SortTh col="opened" title="When the signal reached us: the paper entry. Nothing is executed on the exchange.">opened</SortTh>
          <SortTh col="sl">our SL</SortTh><th style={th}>targets</th>
          <SortTh col="entryRef">entry ref</SortTh>
          <th style={th} title="Live Bybit mark price, with the entry price used for the P&L in parentheses.">price</th>
          <SortTh col="paperPnl" title="What a follower who entered at the signal would be up or down right now. Nothing is actually held.">paper P&amp;L</SortTh>
          <th style={th} title="Each following account's position on this signal">accounts</th>
          <SortTh col="drift">drift</SortTh><SortTh col="updated">updated</SortTh><th style={th}></th>
        </tr></thead>
        <tbody>
          {sorted.map(r => {
            const { positions, open } = r;
            const isAdmin = viewMode === 'admin';
            const unmatched = !isAdmin && !r.has_my_position;
            const orphan = !!r.orphan;
            const drift = !open.length;
            // One "Edit record" button. Admins: full editor when nothing is held, the
            // Levels editor (multi-TP + edit trail) when a position exists. Non-admins keep
            // the per-user editor on their own local-only rows only.
            const editRecord = (isAdmin || userRole === 'admin')
              ? (drift ? onEdit : onEditLedgerLevels)
              : (!unmatched && !orphan && drift ? onEdit : null);
            const rowStyle = flashRow === r.id
              ? { background: 'var(--gold-bg)', transition: 'background 1.4s ease-out' }
              : orphan ? { background: 'rgba(212, 160, 23, 0.08)', borderLeft: '3px solid var(--gold, #d4a017)' }
              : unmatched ? { opacity: 0.65 } : undefined;
            return (
              <React.Fragment key={r.id}>
              <tr id={'ledger-row-' + r.id} style={rowStyle}>
                <td style={td}>
                  <button onClick={() => toggleExpand(r.id)} title="Show this signal's history of changes"
                    style={{
                      background: 'transparent', border: 'none', cursor: 'pointer', padding: 0,
                      color: 'var(--text-4)', fontSize: 10, width: 14,
                      transform: expanded.has(r.id) ? 'rotate(90deg)' : 'none', transition: 'transform .1s',
                    }}>▸</button>
                </td>
                <td style={{ ...td, fontWeight: 600 }}>
                  <SpecSymbolLink symbol={r.symbol} onOpenSpec={onOpenSpec} />
                  {r.symbol_tradable === 0 ? (
                    <span title="Not a Bybit USDT perp (typo, delisted, or not crypto at all) — recorded for the record, but this can never be a real trade"
                          style={{ marginLeft: 6, fontSize: 9, color: 'var(--gold)', fontWeight: 600 }}>not tradable</span>
                  ) : r.symbol_tradable == null && r.sl == null && !r.sl_qualifier && (
                    <span title="Blocked cue: no stop-loss on record — ledger-only, could never have been entered"
                          style={{ marginLeft: 6, fontSize: 9, color: 'var(--gold)', fontWeight: 600 }}>ledger-only · no SL</span>
                  )}
                  {r.split_legs > 1 && (
                    <span title={'One leg of a split entry: this trade was entered in ' + r.split_legs + ' pieces, each its own row from the same message (leg 1 at the market, the rest limits)'}
                          style={{ marginLeft: 6, fontSize: 9, color: 'var(--text-3)', fontWeight: 600 }}>{'leg ' + r.split_leg + '/' + r.split_legs}</span>
                  )}
                  {r.sl != null && r.sl_source === 'default_pct' && (
                    <span title="The trader stated no stop; a default stop (25% from entry) was set so the trade can still be scored and entered"
                          style={{ marginLeft: 6, fontSize: 9, color: 'var(--text-3)', fontWeight: 600 }}>default stop</span>
                  )}
                </td>
                <td style={{ ...td, color: r.side === 'long' ? 'var(--bull)' : 'var(--bear)' }}>{r.side}</td>
                <td style={td}>
                  <span className="chip neutral" style={{ fontSize: 9.5 }}>
                    {POS_STATE_LABEL[r.state] || r.state}
                    {r.state === 'PARTIAL' && r.remaining_pct != null && (
                      <span title="Share of the position still open, compounded across every partial close so far">
                        {' · ' + Math.round(r.remaining_pct) + '% left'}
                      </span>
                    )}
                  </span>
                  <EntryProgress r={r} />
                  {orphan && <span style={{ display: 'block', marginTop: 2, fontSize: 9, color: 'var(--gold, #d4a017)', fontWeight: 600 }}>⚠ still open on exchange</span>}
                  <EligMark r={r} />
                </td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)' }}>
                  {r.first_post_id && onViewMessage ? (
                    <span onClick={() => onViewMessage(r.first_post_id)} style={{ cursor: 'pointer' }}
                          title="View the message that opened this signal">
                      <OpenedCell r={r} />
                      <span style={{ marginLeft: 5, fontSize: 9, color: 'var(--gold)' }}>↗</span>
                    </span>
                  ) : <OpenedCell r={r} />}
                </td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)' }}>
                  {r.sl_qualifier
                    ? <span title={(r.sl_qualifier === 'in_profit'
                          ? 'Stop moved into profit: it now sits beyond entry on the winning side. The exact level was not stated.'
                          : 'Stop moved to breakeven, at entry. No level was stated.')
                          + (r.sl != null ? ' Last stated level: ' + fmtNum(r.sl) : '')}>
                        <span style={{ color: 'var(--bull)' }}>
                          {(r.sl_qualifier === 'in_profit' ? (r.side === 'long' ? '> ' : '< ') : '≈ ')
                            + (r.entry_price != null ? fmtNum(r.entry_price)
                              : r.reference_price != null ? fmtNum(r.reference_price) : 'entry')}
                        </span>
                        {r.sl != null && (
                          <span style={{ color: 'var(--text-4)', textDecoration: 'line-through', marginLeft: 6, fontSize: 10 }}>
                            {fmtNum(r.sl)}
                          </span>
                        )}
                      </span>
                    : r.sl == null
                      ? <span title="Blocked: no stop-loss extracted. Hard rule — no SL, no order. Recorded in the ledger only; could never have been entered."
                              style={{ color: 'var(--gold)', fontWeight: 600 }}>blocked · no SL</span>
                      : fmtNum(r.sl)}
                  <ManualSourceChip source={r.sl_source} />
                </td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)', color: 'var(--text-3)' }}>
                  {(r.tps || []).map(fmtNum).join(' · ') || '—'}
                  <ManualSourceChip source={r.tps_source} />
                </td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)', color: 'var(--text-3)' }}
                    title={r.entry_type === 'dca_zone'
                      ? 'The trader\'s stated entry bound. For "CMP till X" this is the far end of the DCA range (the floor for a long), not the fill price.'
                      : r.entry_type === 'market_cmp' && r.reference_price == null
                        ? 'He posted a market entry at current price with no fixed level — "CMP", no DCA floor, no limit price.'
                        : undefined}>
                  {ledgerEntryRefLabel(r)}
                  <EntrySlippage r={r} />
                </td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)' }}>
                  {r.paper && r.paper.mark != null
                    ? <LivePrice value={r.paper.mark} entry={r.entry_price} />
                    : <span title="No live price for this symbol right now." style={{ color: 'var(--text-4)' }}>—</span>}
                </td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)' }}><PaperPnl r={r} /></td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)' }}>
                  {unmatched
                    ? <span className="chip neutral" style={{ fontSize: 9, color: 'var(--text-4)' }}>Not on your account</span>
                    : positions.length ? positions.map(p => (
                    <div key={p.id} title={`${p.account_label}${p.account_owner ? ' (' + p.account_owner + ')' : ''} · ${p.state}${p.skip_reason ? ' · ' + p.skip_reason : ''}`}
                         style={{ whiteSpace: 'nowrap', lineHeight: 1.5 }}>
                      <span style={{ color: 'var(--text-3)', fontFamily: 'var(--font-sans)' }}>{p.account_label}</span>{' '}
                      {POS_OPEN.includes(p.state)
                        ? <span>
                            {`${fmtNum(p.filled_qty)} @ ${fmtNum(p.avg_fill)}`}
                            {p.unrealized_pnl != null && (
                              <span style={{ marginLeft: 6, color: Number(p.unrealized_pnl) >= 0 ? 'var(--bull)' : 'var(--bear)' }}>
                                {(Number(p.unrealized_pnl) >= 0 ? '+' : '') + fmtUsd(p.unrealized_pnl)}
                              </span>
                            )}
                          </span>
                        : <span style={{ color: 'var(--text-4)' }}>{POS_STATE_LABEL[p.state] || p.state.toLowerCase()}</span>}
                      {isAdmin && (p.edits || []).slice(0, 3).map(e => (
                        <div key={e.id} style={{ fontSize: 9.5, color: 'var(--text-4)', fontFamily: 'var(--font-sans)', lineHeight: 1.4 }}>
                          {e.field} {fmtNum(e.old_value)} → {fmtNum(e.new_value)} <span style={{ opacity: 0.6 }}>(manual, {agoFrom(e.at)})</span>
                        </div>
                      ))}
                    </div>
                  )) : <span style={{ color: 'var(--text-4)' }}>none</span>}
                </td>
                <td style={td}>
                  {unmatched
                    ? <span style={{ color: 'var(--text-4)', fontSize: 10.5 }}>—</span>
                    : orphan
                    ? <span style={{ color: 'var(--gold, #d4a017)', fontSize: 10.5, fontWeight: 600 }}>⚠ {open.length} held — needs close</span>
                    : drift
                    ? (isAdmin
                      ? <span title="Ledger-only: signal is open in the book, but no following account holds a matched exchange position. Expected when every follower is on paper, or entry was blocked (no SL, no slot, not tradable, …)."
                              style={{ color: 'var(--gold)', fontSize: 10.5, fontWeight: 600 }}>⚠ ledger-only · no position</span>
                      : <span style={{ color: 'var(--text-4)', fontSize: 10.5 }}>no position</span>)
                    : <span style={{ color: 'var(--bull)', fontSize: 10.5 }}>{open.length} held</span>}
                </td>
                <td style={{ ...td, color: 'var(--text-4)' }}>{agoFrom(r.updated_at)}</td>
                <td style={td}>
                  {!unmatched && open.map(p => (
                    <div key={p.id} style={{ display: 'flex', gap: 4, marginBottom: 3 }}>
                      <button onClick={() => onRequestClose({
                        row: r, accountId: p.account_id, accountLabel: p.account_label,
                        exch: { contracts: p.filled_qty, entry_price: p.avg_fill, unrealized_pnl: p.unrealized_pnl },
                      })} style={{
                        padding: '3px 9px', borderRadius: 5, cursor: 'pointer', fontSize: 10.5,
                        background: 'transparent', border: '1px solid var(--bear)', color: 'var(--bear)',
                        fontFamily: 'var(--font-sans)', fontWeight: 600, whiteSpace: 'nowrap',
                      }} title={orphan
                        ? `Close orphaned position on ${p.account_label} — strategy already expired/closed this signal`
                        : `Flatten ${p.account_label}'s position immediately, ahead of the trader's stop or target`}>
                        Close{open.length > 1 ? ' · ' + p.account_label : ''}
                      </button>
                      {onEditPosition && (p.state === 'OPEN' || p.state === 'PARTIAL') && (
                        <button onClick={() => onEditPosition({ row: r, pos: p })} style={{
                          padding: '3px 9px', borderRadius: 5, cursor: 'pointer', fontSize: 10.5,
                          background: 'transparent', border: '1px solid var(--gold)', color: 'var(--gold)',
                          fontFamily: 'var(--font-sans)', fontWeight: 600, whiteSpace: 'nowrap',
                        }} title={`Edit SL, TP, or size on ${p.account_label}`}>
                          Amend on exchange{open.length > 1 ? ' · ' + p.account_label : ''}
                        </button>
                      )}
                    </div>
                  ))}
                  {onViewChart && r.symbol_tradable !== 0 && (
                    <button onClick={() => onViewChart(r.id)} style={{
                      padding: '3px 9px', borderRadius: 5, cursor: 'pointer', fontSize: 10.5,
                      background: 'transparent', border: '1px solid var(--border-soft)', color: 'var(--text-3)',
                      fontFamily: 'var(--font-sans)', fontWeight: 600, whiteSpace: 'nowrap',
                      marginBottom: (open.length || (!unmatched && !orphan && drift)) ? 3 : 0,
                    }} title="Price chart with entry/SL/targets marked, and how far each one is from the current price">Chart</button>
                  )}
                  {onAsk && (
                    <button onClick={() => onAsk(r)} style={{
                      padding: '3px 9px', borderRadius: 5, cursor: 'pointer', fontSize: 10.5,
                      background: 'transparent', border: '1px solid var(--border-soft)', color: 'var(--gold)',
                      fontFamily: 'var(--font-sans)', fontWeight: 600, whiteSpace: 'nowrap', display: 'block', marginBottom: 3,
                    }} title="Ask the assistant about this trade">✦ Ask</button>
                  )}
                  {!unmatched && !orphan && drift && r.symbol_tradable !== 0 && onResend && (
                    <button onClick={() => onResend({ row: r })} style={{
                      padding: '3px 9px', borderRadius: 5, cursor: 'pointer', fontSize: 10.5,
                      background: 'transparent', border: '1px solid var(--gold)', color: 'var(--gold)',
                      fontFamily: 'var(--font-sans)', fontWeight: 600, whiteSpace: 'nowrap',
                    }} title="Re-check against the current price and send now if it's still a valid entry">Resend</button>
                  )}
                  {editRecord && (
                    <button onClick={() => editRecord(r)} style={{
                      padding: '3px 9px', borderRadius: 5, cursor: 'pointer', fontSize: 10.5,
                      background: 'transparent', border: '1px solid var(--gold)', color: 'var(--gold)',
                      fontFamily: 'var(--font-sans)', fontWeight: 600, whiteSpace: 'nowrap',
                      marginTop: open.length ? 3 : 0,
                    }} title={drift
                      ? 'Fix the signal record (symbol, side, entry, SL, TP) — no exchange position, nothing to amend'
                      : 'Edit stop / targets on the ledger record — does not push to the exchange (use Amend on exchange for that)'}>
                      Edit record
                    </button>
                  )}
                  {onReanalyze && (r.sl == null || !(r.tps && r.tps.length) || r.entry_price == null) && (
                    <button onClick={() => onReanalyze({ row: r })} style={{
                      padding: '3px 9px', borderRadius: 5, cursor: 'pointer', fontSize: 10.5,
                      background: 'transparent', border: '1px solid var(--border-soft)', color: 'var(--text-3)',
                      fontFamily: 'var(--font-sans)', fontWeight: 600, whiteSpace: 'nowrap',
                      marginTop: 3,
                    }} title="Re-read the original message (text + chart) and fill in SL, targets, or entry price — only where this signal still has none on record">
                      Re-analyze
                    </button>
                  )}
                </td>
              </tr>
              {expanded.has(r.id) && <SignalHistorySubrow signalId={r.id} colSpan={14} onViewMessage={onViewMessage} />}
              </React.Fragment>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}

// ── trade history: how closed/expired/cancelled signals ended ──────────────
const OUTCOME_LABEL = {
  tp_hit: ['reached target', 'var(--bull)'], sl_hit: ['stopped out', 'var(--bear)'],
  no_resolution: ['no stop or target hit within 7d (simulated)', 'var(--text-3)'],
  never_filled: ['entry price never reached', 'var(--text-4)'],
  no_targets: ['no target — never scoreable', 'var(--text-4)'],
  no_candles: ['no market data for this symbol/period', 'var(--text-4)'],
  no_r: ['no valid stop distance', 'var(--text-4)'],
  // A closed trade scored at the price the ledger closed it at (src/ledger_result.js).
  closed_tp: ['hit final target', 'var(--bull)'], closed_stop: ['stopped out', 'var(--bear)'],
  closed_stop_profit: ['stopped in profit', 'var(--bull)'], closed_be: ['closed at entry', 'var(--text-3)'],
  closed_win: ['closed in profit', 'var(--bull)'], closed_loss: ['closed at a loss', 'var(--bear)'],
};
const STATE_LABEL = {
  // Open signals can appear here when a Realized P&L trade link points at a trade whose paper
  // outcome has resolved while the signal itself is still open in the ledger.
  LIVE: ['still open', 'var(--gold)'], PARTIAL: ['still open (partly closed)', 'var(--gold)'],
  WATCHING: ['watching', 'var(--text-3)'],
  CLOSED: ['closed', 'var(--text-2)'], CANCELLED: ['cancelled', 'var(--text-4)'],
  EXPIRED: ['expired (went silent)', 'var(--text-4)'], REJECTED: ['rejected', 'var(--bear)'],
};

const HISTORY_PAGE_SIZE = 20;
const pgBtn = {
  padding: '3px 8px', borderRadius: 5, cursor: 'pointer', fontSize: 11,
  background: 'transparent', border: '1px solid var(--border-soft)', color: 'var(--text-3)',
  fontFamily: 'var(--font-mono)', fontWeight: 600,
};

// Slippage vs. the price the trader stated: how far our actual fill landed from it,
// as a % of the stated price. Same formula as the Entry slippage card
// (src/accounts_api.js): positive = we paid more (long) / received less (short).
// Only when the trader actually stated a number (limit/split); CMP/DCA have none.
function EntrySlippage({ r }) {
  if (r.reference_price_source !== 'stated') return null;
  const ref = Number(r.reference_price);
  if (!(ref > 0)) return null;
  const fills = (r.positions || []).filter(p => p.avg_fill != null && Number(p.filled_qty) > 0);
  if (!fills.length) {
    // Nothing was bought (skipped/cancelled/unfilled): show how far the paper entry — the
    // price when the signal reached us — already sat from the stated price. Muted and
    // labelled "paper" so it is never read as execution slippage (the card excludes it).
    const paper = Number(r.entry_price);
    if (!(paper > 0)) return null;
    const gap = ((r.side === 'long' ? paper - ref : ref - paper) / ref) * 100;
    return (
      <span style={{ display: 'block', fontSize: 10, color: 'var(--text-4)', cursor: 'help' }}
            title={`Paper gap, not a fill: nothing was bought. Stated ${fmtNum(ref)}, the price when the signal reached us was ${fmtNum(paper)}. `
              + 'Positive means the market was already worse than the trader\'s stated price (higher for a long, lower for a short). '
              + 'Not included in the Entry slippage card.'}>
        {(gap >= 0 ? '+' : '−') + Math.abs(gap).toFixed(2) + '% paper'}
      </span>
    );
  }
  const qty = fills.reduce((a, p) => a + Number(p.filled_qty), 0);
  const fill = fills.reduce((a, p) => a + Number(p.avg_fill) * Number(p.filled_qty), 0) / qty;
  const pct = ((r.side === 'long' ? fill - ref : ref - fill) / ref) * 100;
  const color = Math.abs(pct) < 0.005 ? 'var(--text-4)' : pct > 0 ? 'var(--bear)' : 'var(--bull)';
  return (
    <span style={{ display: 'block', fontSize: 10, color, cursor: 'help' }}
          title={'Entry slippage: how far our real fill landed from the price the trader stated. '
            + `Stated ${fmtNum(ref)}, we filled at ${fmtNum(fill)}` + (fills.length > 1 ? ' (average across accounts)' : '') + '. '
            + 'Positive (red) means we paid more than he stated (long) or received less (short) — a worse entry. '
            + 'Negative (green) means a better entry. Only shown when the trader stated a price; market/DCA setups have none to compare against.'}>
      {(pct >= 0 ? '+' : '−') + Math.abs(pct).toFixed(2) + '%'}
    </span>
  );
}

// The parts a closed trade was exited in (src/ledger_result.js): profit taken at a target, then the
// rest at the ledger's close. The trade's R is the size-weighted sum of these.
const EXIT_KIND_LABEL = { partial_tp: 'took profit', close: 'closed' };
function ExitsSubrow({ exits, total, colSpan }) {
  const cell = { padding: '3px 10px', fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text-2)' };
  const head = { ...cell, fontFamily: 'var(--font-sans)', fontSize: 10, color: 'var(--text-4)', textAlign: 'left' };
  return (
    <tr>
      <td colSpan={colSpan} style={{ padding: '4px 14px 8px 36px', background: 'var(--bg-2, transparent)' }}>
        <table style={{ borderCollapse: 'collapse' }}>
          <thead><tr>
            <th style={head}>Exit</th><th style={head}>Size</th><th style={head}>Price</th><th style={head}>R</th><th style={head}>When</th>
          </tr></thead>
          <tbody>
            {exits.map((e, i) => (
              <tr key={i}>
                <td style={{ ...cell, fontFamily: 'var(--font-sans)' }}>
                  {EXIT_KIND_LABEL[e.kind] || e.kind}
                  {e.assumed && <span title="He did not state a percentage; the default 50% is assumed." style={{ marginLeft: 4, color: 'var(--gold)', cursor: 'help' }}>~</span>}
                  {e.price_source === 'target_level' && <span title="His post reached us after price had fallen back, so this uses the target he announced." style={{ marginLeft: 4, color: 'var(--text-4)', cursor: 'help' }}>@target</span>}
                </td>
                <td style={cell}>{Math.round(e.size * 100)}%</td>
                <td style={cell}>{fmtNum(e.price)}</td>
                <td style={{ ...cell, color: e.r >= 0 ? 'var(--bull)' : 'var(--bear)' }}>{signedR(e.r)}</td>
                <td style={{ ...cell, color: 'var(--text-3)' }}>{e.at ? fmtStamp(e.at) : '—'}</td>
              </tr>
            ))}
            <tr>
              <td style={{ ...cell, fontFamily: 'var(--font-sans)', fontWeight: 600 }}>Whole trade</td>
              <td style={cell}>100%</td><td style={cell}></td>
              <td style={{ ...cell, fontWeight: 600, color: total >= 0 ? 'var(--bull)' : 'var(--bear)' }}>{signedR(total)}</td>
              <td style={cell}></td>
            </tr>
          </tbody>
        </table>
      </td>
    </tr>
  );
}

const HISTORY_SORT_DEFAULT_DIR = {
  closed: 'desc', opened: 'desc', r: 'desc',
  symbol: 'asc', side: 'asc', state: 'asc', outcome: 'asc', account: 'asc',
};

function historySortValue(row, key) {
  if (key === 'symbol') return (row.symbol || '').toLowerCase();
  if (key === 'side') return (row.side || '').toLowerCase();
  if (key === 'state') return (row.state || '').toLowerCase();
  if (key === 'outcome') return (row.outcome_a || '').toLowerCase();
  if (key === 'r') {
    // Number(null) is 0 and Number.isFinite(0) is true — same footgun as fmtNum;
    // treat missing as unscored, not zero.
    if (row.r_multiple_a == null || row.r_multiple_a === '') return null;
    const n = Number(row.r_multiple_a);
    return Number.isFinite(n) ? n : null;
  }
  if (key === 'opened' || key === 'closed') {
    const iso = key === 'opened' ? row.created_at : row.updated_at;
    if (!iso) return null;
    const t = Date.parse(String(iso).endsWith('Z') ? iso : iso + 'Z');
    return Number.isFinite(t) ? t : null;
  }
  if (key === 'account') return row.has_my_position ? 1 : 0;
  return null;
}

function historyEntryPrice(row) {
  const fill = (row.positions || []).find(p => p.avg_fill != null);
  if (fill) return fill.avg_fill;
  return row.entry_price != null ? row.entry_price : row.reference_price;
}

// A position still open on the exchange right now, even though this SIGNAL ended
// (closed/expired/cancelled) — the two are independent lifecycles. Surfaced in the
// exit column instead of a price, so it's never confused with a real close.
function historyOpenPosition(row) {
  return (row.positions || []).find(p => POS_OPEN.includes(p.state));
}

// Prefer the REAL fill this account got when the exchange closed it (our own
// close, a filled TP, or a stop trigger — see order_manager's avg_close). Next,
// a price the operator recorded by hand closing a ledger-only entry (see
// ledger_edit.close_signal) — the two never coexist, since close_signal refuses
// a signal that has a real position. Falls back to exit_price_a, the
// candidate-live-policy's hypothetical exit, only when neither real number exists.
function historyExitPrice(row) {
  const closedFill = (row.positions || []).find(p => p.avg_close != null);
  if (closedFill) return closedFill.avg_close;
  if (row.close_price != null) return row.close_price;
  return row.exit_price_a;
}

// How this account's REAL position ended, from its own fill/close against OUR stop and
// targets (not the trader's — the simulated outcome_a walks his levels, which can differ
// from the ones we actually placed). Null while nothing real has closed.
function historyRealOutcome(row) {
  const p = (row.positions || []).find(x => x.avg_close != null && x.avg_fill != null);
  if (!p) return null;
  const close = Number(p.avg_close), fill = Number(p.avg_fill), sl = Number(p.our_sl);
  const long = row.side === 'long';
  const near = (a, b) => b > 0 && Math.abs(a - b) / b <= 0.001;
  const tps = (p.our_tps || []).map(Number).filter(n => n > 0);
  const px = fmtNum(close);
  if (sl > 0 && (near(close, sl) || (long ? close < sl : close > sl)))
    return [`stopped out @ ${px}`, 'var(--bear)', p];
  if (tps.some(tp => near(close, tp) || (long ? close > tp : close < tp)))
    return [`reached target @ ${px}`, 'var(--bull)', p];
  const win = long ? close > fill : close < fill;
  return [`closed @ ${px}`, win ? 'var(--bull)' : 'var(--bear)', p];
}

function sortHistoryRows(rows, sortKey, sortDir) {
  const dir = sortDir === 'asc' ? 1 : -1;
  return [...rows].sort((a, b) => {
    const av = historySortValue(a, sortKey);
    const bv = historySortValue(b, sortKey);
    if (av == null && bv == null) return 0;
    if (av == null) return 1; // nulls last either way
    if (bv == null) return -1;
    if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
    return String(av).localeCompare(String(bv), 'en-GB') * dir;
  });
}

function HistoryTable({ rows, onViewMessage, onViewTimeline, viewMode, onOpenSpec, onDelete }) {
  const [symbolFilter, setSymbolFilter] = useS('all');
  const [rFilter, setRFilter] = useS('all');
  const [sideFilter, setSideFilter] = useS('all');
  const [page, setPage] = useS(0);
  // Match GET /api/signals/ledger closed: ORDER BY updated_at DESC
  const [sortKey, setSortKey] = useS('closed');
  const [sortDir, setSortDir] = useS('desc');
  const [expanded, setExpanded] = useS(() => new Set());
  const toggleExpand = (id) => setExpanded(prev => {
    const next = new Set(prev);
    next.has(id) ? next.delete(id) : next.add(id);
    return next;
  });

  if (rows == null) {
    return <div style={{ padding: '22px 0', textAlign: 'center', color: 'var(--text-4)', fontSize: 12 }}>Loading…</div>;
  }
  if (!rows.length) {
    return <div style={{ padding: '22px 0', textAlign: 'center', color: 'var(--text-4)', fontSize: 12 }}>No closed signals yet.</div>;
  }

  const symbols = [...new Set(rows.map(r => r.symbol).filter(Boolean))].sort();
  const filtered = rows.filter(r => {
    if (symbolFilter !== 'all' && r.symbol !== symbolFilter) return false;
    if (sideFilter !== 'all' && r.side !== sideFilter) return false;
    if (rFilter === 'positive' && !(r.r_multiple_a > 0)) return false;
    if (rFilter === 'negative' && !(r.r_multiple_a < 0)) return false;
    if (rFilter === 'unscored' && r.r_multiple_a != null) return false;
    return true;
  });
  const sorted = sortHistoryRows(filtered, sortKey, sortDir);
  const totalPages = Math.max(1, Math.ceil(sorted.length / HISTORY_PAGE_SIZE));
  const safePage = Math.min(page, totalPages - 1);
  const paged = sorted.slice(safePage * HISTORY_PAGE_SIZE, (safePage + 1) * HISTORY_PAGE_SIZE);

  const selectStyle = {
    padding: '3px 8px', borderRadius: 6, fontSize: 11, cursor: 'pointer',
    fontFamily: 'var(--font-sans)', background: 'var(--bg-elev)',
    border: '1px solid var(--border-soft)', color: 'var(--text)',
  };

  const th = { textAlign: 'left', fontSize: 9.5, color: 'var(--text-4)', letterSpacing: '0.09em',
               textTransform: 'uppercase', padding: '0 8px 7px', fontWeight: 500 };
  const thSort = { ...th, cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' };
  const td = { padding: '7px 8px', borderTop: '1px solid var(--border-soft)', fontSize: 11.5 };

  const onSort = (key) => {
    if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
    else { setSortKey(key); setSortDir(HISTORY_SORT_DEFAULT_DIR[key] || 'asc'); }
    setPage(0);
  };
  const sortMark = (key) => {
    if (sortKey !== key) return <span style={{ opacity: 0.35, marginLeft: 3 }}>↕</span>;
    return <span style={{ color: 'var(--gold, #d4a017)', marginLeft: 3 }}>{sortDir === 'asc' ? '▲' : '▼'}</span>;
  };
  const SortTh = ({ col, children, title }) => (
    <th style={{ ...thSort, color: sortKey === col ? 'var(--text-2)' : th.color, fontWeight: sortKey === col ? 700 : 500 }}
        title={title} onClick={() => onSort(col)} aria-sort={sortKey === col ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}>
      {children}{sortMark(col)}
    </th>
  );

  return (
    <div>
      <div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', marginBottom: 10, padding: '0 2px' }}>
        <select value={symbolFilter} onChange={e => { setSymbolFilter(e.target.value); setPage(0); }} style={selectStyle}>
          <option value="all">All coins</option>
          {symbols.map(s => <option key={s} value={s}>{s}</option>)}
        </select>
        <select value={sideFilter} onChange={e => { setSideFilter(e.target.value); setPage(0); }} style={selectStyle}>
          <option value="all">Both sides</option>
          <option value="long">Long only</option>
          <option value="short">Short only</option>
        </select>
        <select value={rFilter} onChange={e => { setRFilter(e.target.value); setPage(0); }} style={selectStyle}>
          <option value="all">All R</option>
          <option value="positive">Winners (positive R)</option>
          <option value="negative">Losers (negative R)</option>
          <option value="unscored">Unscored</option>
        </select>
        <span style={{ fontSize: 10.5, color: 'var(--text-4)' }}>{sorted.length} / {rows.length} trades</span>
      </div>
      <div style={{ overflowX: 'auto' }}>
        <table style={{ width: '100%', borderCollapse: 'collapse' }}>
          <thead><tr>
            <th style={{ ...th, width: 20 }}></th>
            <SortTh col="symbol">symbol</SortTh>
            <SortTh col="side">side</SortTh>
            <SortTh col="state" title="What the trader's own account says happened">final state</SortTh>
            <SortTh col="outcome" title="What actually happened against Bybit candles under the candidate live policy (bracket, TP1) — not what he says">outcome</SortTh>
            <th style={th} title="Price we paid: your fill on this account if you had one, otherwise the signal's entry price">entry</th>
            <th style={th} title="Exit price under the candidate live policy, when the trade was scored">exit</th>
            <th style={th} title="Current Bybit mark price for this coin">now</th>
            <SortTh col="r" title="R-multiple under candidate live policy">R</SortTh>
            <SortTh col="opened">opened</SortTh>
            <SortTh col="closed" title="Newest closed first by default">closed</SortTh>
            {viewMode !== 'admin' && <SortTh col="account" title="Whether this trade applied to your account">account</SortTh>}
            <th style={th}></th>
          </tr></thead>
          <tbody>
            {paged.map(r => {
              const [stateLabel, stateColor] = STATE_LABEL[r.state] || [r.state, 'var(--text-4)'];
              // "no_resolution" covers two very different things: genuinely still
              // inside the 7-day scoring window (nothing decided yet) vs. aged past
              // it without ever touching SL/TP1 (a final score, marked to market).
              // outcome_resolved tells them apart — this is about the hypothetical
              // candidate-live-policy score, never the real exchange position
              // (that's what the exit column's "still open" badge below is for).
              // A position that really closed on the exchange wins over the simulation:
              // the sim uses the trader's stop, ours can be tighter and fire first.
              const real = historyRealOutcome(r);
              const [outLabel, outColor] = real
                ? [real[0], real[1]]
                : r.outcome_a === 'no_resolution'
                ? (r.outcome_resolved
                    ? OUTCOME_LABEL.no_resolution
                    : ['not decided yet — still tracking', 'var(--gold)'])
                : (OUTCOME_LABEL[r.outcome_a] || ['not yet scored', 'var(--text-4)']);
              const unmatched = viewMode !== 'admin' && !r.has_my_position;
              const openPos = historyOpenPosition(r);
              const colCount = (viewMode !== 'admin' ? 13 : 12);
              return (
                <React.Fragment key={r.id}>
                <tr style={unmatched ? { opacity: 0.65 } : undefined}>
                  <td style={td}>
                    <button onClick={() => toggleExpand(r.id)} title="Show this signal's history of changes"
                      style={{
                        background: 'transparent', border: 'none', cursor: 'pointer', padding: 0,
                        color: 'var(--text-4)', fontSize: 10, width: 14,
                        transform: expanded.has(r.id) ? 'rotate(90deg)' : 'none', transition: 'transform .1s',
                      }}>▸</button>
                  </td>
                  <td style={{ ...td, fontWeight: 600 }}><SpecSymbolLink symbol={r.symbol} onOpenSpec={onOpenSpec} /></td>
                  <td style={{ ...td, color: r.side === 'long' ? 'var(--bull)' : 'var(--bear)' }}>{r.side}</td>
                  <td style={{ ...td, color: stateColor }}>
                    {stateLabel}
                    {r.state === 'EXPIRED' && (
                      <span title="The trader went quiet on this trade — we stopped waiting rather than assume an outcome"
                            style={{ marginLeft: 5, fontSize: 9, color: 'var(--text-4)', cursor: 'help' }}>?</span>
                    )}
                  </td>
                  <td style={{ ...td, color: outColor }}
                      title={real
                        ? `Real result on ${real[2].account_label || 'your account'}: filled ${fmtNum(real[2].avg_fill)}, closed ${fmtNum(real[2].avg_close)} (our stop ${fmtNum(real[2].our_sl)}). The simulated score against the trader's levels is ${r.outcome_a || 'not scored'}.`
                        : r.outcome_a === 'no_resolution'
                        ? (r.outcome_resolved
                          ? 'Simulated: no SL/TP1 hit in the 7 days scored, so this is a final (marked-to-market) score — not a statement about whether the real position is still open (see the exit column).'
                          : 'Still inside the 7-day scoring window — not decided yet. Not a statement about the real exchange position either way.')
                        : undefined}>
                    {outLabel}
                  </td>
                  <td style={{ ...td, fontFamily: 'var(--font-mono)' }}>{fmtNum(historyEntryPrice(r))}</td>
                  <td style={{ ...td, fontFamily: 'var(--font-mono)' }}>
                    {openPos
                      ? <span style={{ color: 'var(--gold)' }}
                              title={`${openPos.account_label}'s position is still open on the exchange right now — this signal ended, the position didn't.`}>
                          ⚠ still open
                        </span>
                      : (r.exits && r.exits.length > 1 && !(r.positions || []).some(p => p.avg_close != null))
                        ? <span title="Average price of the exits, weighted by size. Open the row to see each exit.">
                            {fmtNum(r.avg_exit_price)}
                            <span style={{ display: 'block', fontSize: 9, color: 'var(--text-4)' }}>avg of {r.exits.length} exits</span>
                          </span>
                        : fmtNum(historyExitPrice(r))}
                  </td>
                  <td style={{ ...td, fontFamily: 'var(--font-mono)', color: 'var(--text-3)' }}>{fmtNum(r.current_price)}</td>
                  <td style={{ ...td, fontFamily: 'var(--font-mono)',
                              color: r.r_multiple_a == null ? 'var(--text-4)' : r.r_multiple_a >= 0 ? 'var(--bull)' : 'var(--bear)' }}
                      title={r.result_basis === 'ledger_close'
                        ? 'R of the whole trade at the prices it was exited at, against the stop it started with'
                          + (r.exits && r.exits.length > 1 ? ` (${r.exits.length} exits — open the row for each).` : '.')
                          + (r.sim_r_multiple_a != null ? ` The simulated first-target score is ${signedR(r.sim_r_multiple_a)}.` : '')
                        : 'Simulated: entry to the first target or the original stop, whichever price reached first.'}>
                    {r.r_multiple_a == null ? '—' : signedR(r.r_multiple_a)}
                  </td>
                  <td style={{ ...td, fontFamily: 'var(--font-mono)', color: 'var(--text-3)' }}>{fmtStamp(r.created_at)}</td>
                  <td style={{ ...td, fontFamily: 'var(--font-mono)', color: 'var(--text-3)' }}>{['LIVE', 'PARTIAL', 'WATCHING'].includes(r.state) ? '—' : fmtStamp(r.updated_at)}</td>
                  {viewMode !== 'admin' && (
                    <td style={td}>
                      {unmatched
                        ? <span className="chip neutral" style={{ fontSize: 9, color: 'var(--text-4)' }}>Not on your account</span>
                        : <span className="chip neutral" style={{ fontSize: 9, color: 'var(--bull)' }}>Applied</span>}
                    </td>
                  )}
                  <td style={{ ...td, whiteSpace: 'nowrap' }}>
                    {onViewTimeline && (
                      <button onClick={() => onViewTimeline(r.id)}
                              title="Every message that touched this signal, in order — and how it ended"
                              style={{
                        padding: '2px 8px', borderRadius: 5, cursor: 'pointer', fontSize: 10,
                        background: 'transparent', border: '1px solid var(--border-soft)', color: 'var(--text-3)',
                        fontFamily: 'var(--font-sans)', marginRight: onDelete ? 6 : 0,
                      }}>timeline</button>
                    )}
                    {onDelete && !['LIVE', 'PARTIAL', 'WATCHING'].includes(r.state) && (
                      <button onClick={() => onDelete(r)} title="Permanently delete this row (only ever possible for entries that never held a real position)" style={{
                        padding: '2px 8px', borderRadius: 5, cursor: 'pointer', fontSize: 10,
                        background: 'transparent', border: '1px solid var(--border-soft)', color: 'var(--bear)',
                        fontFamily: 'var(--font-sans)',
                      }}>delete</button>
                    )}
                  </td>
                </tr>
                {expanded.has(r.id) && r.exits && r.exits.length > 0 && <ExitsSubrow exits={r.exits} total={r.r_multiple_a} colSpan={colCount} />}
                {expanded.has(r.id) && <SignalHistorySubrow signalId={r.id} colSpan={colCount} onViewMessage={onViewMessage} />}
                </React.Fragment>
              );
            })}
          </tbody>
        </table>
      </div>
      {totalPages > 1 && (
        <div style={{ display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'center', padding: '10px 0 2px' }}>
          <button onClick={() => setPage(0)} disabled={safePage === 0}
            style={{ ...pgBtn, opacity: safePage === 0 ? 0.35 : 1 }}>{'<<'}</button>
          <button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={safePage === 0}
            style={{ ...pgBtn, opacity: safePage === 0 ? 0.35 : 1 }}>{'<'}</button>
          <span style={{ fontSize: 11, color: 'var(--text-3)', fontFamily: 'var(--font-mono)', minWidth: 60, textAlign: 'center' }}>
            {safePage + 1} / {totalPages}
          </span>
          <button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={safePage >= totalPages - 1}
            style={{ ...pgBtn, opacity: safePage >= totalPages - 1 ? 0.35 : 1 }}>{'>'}</button>
          <button onClick={() => setPage(totalPages - 1)} disabled={safePage >= totalPages - 1}
            style={{ ...pgBtn, opacity: safePage >= totalPages - 1 ? 0.35 : 1 }}>{'>>'}</button>
        </div>
      )}
    </div>
  );
}

// ── report tab ─────────────────────────────────────────────────────────────
function ReportSummaryCard({ label, value, subtitle, color, title }) {
  return (
    <div className="card" style={{ flex: 1, minWidth: 140 }}>
      <div className="card-b" style={{ padding: '10px 14px' }}>
        <span style={dashLabelStyle}>{label}</span>
        <div className="mono" style={{ fontSize: 20, fontWeight: 600, color: color || 'var(--text)', marginTop: 2 }}
             title={title}>{value}</div>
        {subtitle && <div style={{ fontSize: 10.5, color: 'var(--text-4)', marginTop: 2 }}>{subtitle}</div>}
      </div>
    </div>
  );
}

// Adverse-move percentages: positive = worse for a follower, so red; negative = better.
function reportAdversePct(v) {
  if (v == null) return '—';
  return (v >= 0 ? '+' : '−') + Math.abs(v).toFixed(2) + '%';
}
function reportAdverseColor(v) {
  if (v == null || Math.abs(v) < 0.005) return 'var(--text-4)';
  return v > 0 ? 'var(--bear)' : 'var(--bull)';
}

const REPORT_OUTCOME_CHIP = {
  tp_hit: ['TP1', 'var(--bull)'], sl_hit: ['SL', 'var(--bear)'],
  no_resolution: ['7d timeout', 'var(--text-4)'],
};

function ReportTab({ strategyId, period, customSince, customUntil, envFilter, viewMode }) {
  const [data, setData] = useS(null);
  const [err, setErr] = useS(null);
  const [loading, setLoading] = useS(false);
  const isAdmin = viewMode === 'admin';

  useE(() => {
    if (!strategyId) return;
    setLoading(true);
    const qs = new URLSearchParams();
    if (envFilter !== 'all') qs.set('env', envFilter);
    if (period === 'custom') {
      if (customSince) qs.set('since', customSince);
      if (customUntil) qs.set('until', customUntil);
    } else if (period !== 'all') {
      qs.set('period', period);
    }
    const basePath = isAdmin
      ? '/api/signals/strategy-report' + (qs.toString() ? '?' + qs.toString() : '')
      : meStrategyUrl(strategyId, 'report') + (qs.toString() ? '?' + qs.toString() : '');
    const path = isAdmin ? sigWithStrategy(basePath, strategyId) : basePath;
    fetch(path).then(r => r.json())
      .then(j => { if (j.ok) { setData(j.data); setErr(null); } else setErr(j.error || 'unknown error'); })
      .catch(e => setErr(String(e.message || e)))
      .finally(() => setLoading(false));
  }, [strategyId, period, customSince, customUntil, envFilter, viewMode]);

  if (err) return <div style={{ padding: 20, color: 'var(--bear)', fontSize: 12 }}>{err}</div>;
  if (loading || !data) return <div style={{ padding: 20, color: 'var(--text-4)', fontSize: 12 }}>Loading report…</div>;

  const { summary: s, trades } = data;
  if (!s.n) return <div style={{ padding: 20, color: 'var(--text-4)', fontSize: 12 }}>No resolved trades in this period.</div>;

  const th = { textAlign: 'left', fontSize: 9.5, color: 'var(--text-4)', letterSpacing: '0.09em',
               textTransform: 'uppercase', padding: '0 8px 7px', fontWeight: 500 };
  const td = { padding: '7px 8px', borderTop: '1px solid var(--border-soft)', fontSize: 11.5 };

  return (
    <div>
      <div style={{ display: 'flex', gap: 10, marginBottom: 14, flexWrap: 'wrap' }}>
        <ReportSummaryCard label="Win rate" value={s.win_rate != null ? s.win_rate.toFixed(1) + '%' : '—'}
          subtitle={`${s.wins}W / ${s.losses}L`} color={s.win_rate >= 50 ? 'var(--bull)' : 'var(--bear)'} />
        <ReportSummaryCard label="Avg R (net)" value={s.avg_r != null ? signedR(s.avg_r) : '—'}
          subtitle={s.total_r != null ? `Total: ${signedR(s.total_r)}` : null}
          color={s.avg_r != null ? pnlColor(s.avg_r) : undefined} />
        <ReportSummaryCard label="Expectancy" value={s.expectancy_r != null ? signedR(s.expectancy_r) : '—'}
          color={s.expectancy_r != null ? pnlColor(s.expectancy_r) : undefined}
          title="(win_rate * avg_win_R) - (loss_rate * avg_loss_R) — the expected R per trade" />
        <ReportSummaryCard label="Account at 1% risk" value={s.account_pct_at_1pct_risk != null ? signedPct(s.account_pct_at_1pct_risk) : '—'}
          subtitle={[s.avg_return_pct != null ? `avg move ${signedPct(s.avg_return_pct)} per trade` : null,
                     s.real_pnl_total != null ? `(${fmtUsd(s.real_pnl_total)}) from ${s.real_pnl_n} live trade${s.real_pnl_n === 1 ? '' : 's'}` : null]
                    .filter(Boolean).join(' · ') || null}
          color={s.account_pct_at_1pct_risk != null ? pnlColor(s.account_pct_at_1pct_risk) : undefined}
          title="A follower risking 1% of the balance on every trade, compounded in close order. Each trade's own % move is in the table." />
        <ReportSummaryCard label="Best / Worst"
          value={s.best ? `${s.best.symbol} ${signedR(s.best.r)}` : '—'}
          subtitle={s.worst ? `${s.worst.symbol} ${signedR(s.worst.r)}` : null}
          color="var(--text)" />
      </div>

      <div style={{ overflowX: 'auto' }}>
        <table style={{ width: '100%', borderCollapse: 'collapse' }}>
          <thead><tr>
            <th style={th}>symbol</th><th style={th}>side</th>
            <th style={th} title="The price the trader stated (limit/split setups only; market/DCA setups have no stated price).">stated</th>
            <th style={th} title="Bybit market price at the moment the message reached us.">market at signal</th>
            <th style={th} title="How far the market had already moved from the trader's stated price when we saw the message. Positive (red) = worse for a follower: for a long the market was already above his price, for a short below it.">stated → market</th>
            <th style={th} title="The price the scorer treats as our entry: the stated price for limit/split setups, the market price when seen for CMP/DCA.">entry</th>
            <th style={th} title="What we actually paid on a real position (size-weighted across accounts), when one exists.">our fill</th>
            <th style={th} title="Real slippage: our actual fill vs the trader's stated price. Positive (red) = we paid more (long) or received less (short).">fill vs stated</th>
            <th style={th}>exit</th><th style={th}>%</th><th style={th}>R</th>
            <th style={th}>fees R</th><th style={th}>outcome</th>
            <th style={th}>opened</th><th style={th}>closed</th>
          </tr></thead>
          <tbody>
            {trades.map(t => (
              <tr key={t.signal_id}>
                <td style={{ ...td, fontWeight: 600 }}>{t.symbol}</td>
                <td style={{ ...td, color: t.side === 'long' ? 'var(--bull)' : 'var(--bear)' }}>{t.side}</td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)', color: 'var(--text-3)' }}>{fmtNum(t.stated_price)}</td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)', color: 'var(--text-3)' }}>{fmtNum(t.market_at_signal)}</td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)', color: reportAdverseColor(t.stated_vs_market_pct) }}>{reportAdversePct(t.stated_vs_market_pct)}</td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)' }}>{fmtNum(t.entry_price)}</td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)', color: 'var(--text-3)' }}>{fmtNum(t.our_fill)}</td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)', color: reportAdverseColor(t.fill_vs_stated_pct) }}>{reportAdversePct(t.fill_vs_stated_pct)}</td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)' }}>{fmtNum(t.exit_price)}</td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)', color: t.pct_return != null ? pnlColor(t.pct_return) : undefined }}>
                  {t.pct_return != null ? signedPct(t.pct_return) : '—'}
                  {t.real_pnl != null && <span style={{ fontSize: 9.5, color: 'var(--text-4)', marginLeft: 4 }}>({fmtUsd(t.real_pnl)})</span>}
                </td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)', color: t.net_r != null ? pnlColor(t.net_r) : undefined }}>
                  {t.net_r != null ? signedR(t.net_r) : '—'}
                </td>
                <td style={{ ...td, fontFamily: 'var(--font-mono)', color: 'var(--text-4)', fontSize: 10 }}>
                  {t.fees_r != null ? t.fees_r.toFixed(4) : '—'}
                </td>
                <td style={td}>
                  {(() => { const [label, color] = REPORT_OUTCOME_CHIP[t.outcome] || [t.outcome || '—', 'var(--text-4)'];
                    return <span className="chip neutral" style={{ fontSize: 9.5, color }}>{label}</span>; })()}
                </td>
                <td style={{ ...td, color: 'var(--text-3)' }}>{fmtStamp(t.opened_at)}</td>
                <td style={{ ...td, color: 'var(--text-3)' }}>{fmtStamp(t.closed_at)}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

// ── signal feed ────────────────────────────────────────────────────────────
function GateTrace({ checks }) {
  const [open, setOpen] = useS(false);
  if (!checks || !checks.length) return null;
  return (
    <div style={{ marginTop: 6 }}>
      <button onClick={() => setOpen(!open)} style={{
        background: 'transparent', border: 'none', cursor: 'pointer', padding: 0,
        color: 'var(--text-4)', fontSize: 10.5, fontFamily: 'var(--font-sans)',
      }}>{open ? '▾' : '▸'} gate trace ({checks.length})</button>
      {open && (
        <div style={{ marginTop: 5, display: 'flex', flexDirection: 'column', gap: 2 }}>
          {checks.map((c, i) => (
            <div key={i} style={{ display: 'flex', gap: 7, fontSize: 10.5, alignItems: 'baseline' }}>
              <span style={{ color: c.pass ? 'var(--bull)' : 'var(--bear)' }}>{c.pass ? '✓' : '✕'}</span>
              <span style={{ color: 'var(--text-3)', minWidth: 116 }}>{c.check}</span>
              <span className="mono" style={{ color: 'var(--text-2)' }}>{c.reason_code}</span>
              {c.detail && <span style={{ color: 'var(--text-4)' }}>{String(c.detail).slice(0, 90)}</span>}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// A long message is clamped so a card stays scannable, but never cut off for good:
// "Show more" expands it in place.
function ExpandableText({ text, limit = 640, style }) {
  const [open, setOpen] = useS(false);
  const long = text.length > limit;
  return (
    <div style={style}>
      <div>{!long || open ? text : text.slice(0, limit) + '…'}</div>
      {long && (
        <button onClick={() => setOpen(o => !o)} style={{
          background: 'none', border: 'none', padding: '4px 0 0', cursor: 'pointer',
          fontSize: 11, color: 'var(--gold)', fontFamily: 'var(--font-sans)', fontWeight: 600,
        }}>{open ? 'Show less' : `Show more (${(text.length - limit).toLocaleString()} more characters)`}</button>
      )}
    </div>
  );
}

function SubEventRow({ s, onJumpToLedger, onAddToLedger }) {
  const tone = ACTION_TONE[s.action] || 'muted';
  const color = tone === 'bull' ? 'var(--bull)' : tone === 'info' ? 'var(--info)'
    : tone === 'gold' ? 'var(--gold)' : 'var(--text-4)';
  return (
    <div style={{
      padding: '7px 9px', background: 'var(--bg-card)', borderRadius: 6,
      border: '1px solid var(--border-soft)', borderLeft: `2px solid ${color}`,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
        <span className="mono" style={{ fontSize: 11, color: 'var(--text)' }}>{s.intent}</span>
        {s.symbol && <span className="chip neutral" style={{ fontSize: 9.5 }}>{s.symbol}{s.side ? ' ' + s.side : ''}</span>}
        {s.extraction && s.extraction.market_price_at_post != null && (
          <span className="mono" title="This coin's price at the moment this message reached us"
            style={{ fontSize: 9.5, color: 'var(--text-4)' }}>
            @ {fmtNum(s.extraction.market_price_at_post)}
          </span>
        )}
        {!s.symbol_tradable && s.symbol && (
          <span title="Not a Bybit USDT perp — ledgered, never ordered"
                style={{ fontSize: 9.5, color: 'var(--text-4)' }}>not tradable</span>
        )}
        {s.signal_id != null && onJumpToLedger && (
          <button onClick={() => onJumpToLedger(s.signal_id)} title="Jump to this signal in the ledger" style={{
            padding: '1px 7px', borderRadius: 9, cursor: 'pointer', fontSize: 9.5, fontWeight: 600,
            background: 'transparent', border: '1px solid var(--gold)', color: 'var(--gold)',
            fontFamily: 'var(--font-sans)',
          }}>↗ ledger</button>
        )}
        {onAddToLedger && (
          <button onClick={() => onAddToLedger(s)}
            title="Create a new manual ledger entry, pre-filled from this message's symbol/side/price and timestamp"
            style={{
              padding: '1px 7px', borderRadius: 9, cursor: 'pointer', fontSize: 9.5,
              fontWeight: 600, background: 'transparent',
              border: '1px solid var(--border-soft)', color: 'var(--text-3)',
              fontFamily: 'var(--font-sans)',
            }}>+ ledger</button>
        )}
        <div style={{ flex: 1 }} />
        {s.confidence != null && (
          <span style={{ fontSize: 10, color: 'var(--text-4)' }}>conf {Number(s.confidence).toFixed(2)}</span>
        )}
        <span className="mono" style={{ fontSize: 10.5, color }}>{s.action || '—'}</span>
        <span className="mono" style={{ fontSize: 10, color: 'var(--text-4)' }} title={s.reason_code || ''}>{reasonLabel(s.reason_code)}</span>
      </div>
      {s.evidence_quote && (
        <div style={{ marginTop: 4, fontSize: 10.5, color: 'var(--text-3)', fontStyle: 'italic' }}>
          “{String(s.evidence_quote).slice(0, 160)}”
        </div>
      )}
      <GateTrace checks={s.checks} />
    </div>
  );
}

// MyMemory's free translation API caps each request around 500 chars — split
// on line boundaries first (falls back to word boundaries for one very long
// line) so a chunk never lands mid-sentence. Shared with widgets-telegram.jsx,
// which loads after this file and reuses these same globals rather than
// redefining them — one translation path for every message card in the app.
function chunkText(text, maxLen = 490) {
  const lines = text.split('\n');
  const chunks = [];
  let current = '';
  for (const line of lines) {
    const sep = current ? '\n' : '';
    if ((current + sep + line).length <= maxLen) {
      current = current + sep + line;
    } else {
      if (current) chunks.push(current);
      if (line.length <= maxLen) {
        current = line;
      } else {
        const words = line.split(' ');
        current = '';
        for (const word of words) {
          const sep2 = current ? ' ' : '';
          if ((current + sep2 + word).length <= maxLen) {
            current = current + sep2 + word;
          } else {
            if (current) chunks.push(current);
            current = word.length <= maxLen ? word : word.slice(0, maxLen);
          }
        }
      }
    }
  }
  if (current) chunks.push(current);
  return chunks;
}

async function translateToPT(text) {
  const chunks = chunkText(text);
  const parts = [];
  for (const chunk of chunks) {
    const url = 'https://api.mymemory.translated.net/get?q=' + encodeURIComponent(chunk) + '&langpair=en|pt-BR';
    const res = await fetch(url);
    const data = await res.json();
    const out = data.responseData && data.responseData.translatedText;
    if (!out || out === chunk) throw new Error('No translation returned');
    parts.push(out);
  }
  return parts.join('\n');
}

// Copy / translate-to-Portuguese / send-to-WhatsApp for one message's text.
// Generic — any page that renders a raw trader message can drop this in, not
// tied to a specific trader. Translation is on-demand via MyMemory's free API
// (no key, no per-call cost — same choice as the Telegram feed page), shown
// inline with a way back to the original.
// ── media carousel: an album post carries more than one photo — show all of
// them, one at a time, instead of silently dropping every image but the first.
// Click any image to open it full-size in a lightbox (Esc or the arrow keys
// work there too, same left/right navigation as the inline carousel). ──
function MediaCarousel({ urls, imgStyle }) {
  const [idx, setIdx] = useS(0);
  const [lightbox, setLightbox] = useS(false);
  const n = (urls || []).length;

  useE(() => {
    if (!lightbox) return undefined;
    const onKey = (e) => {
      if (e.key === 'Escape') setLightbox(false);
      else if (e.key === 'ArrowLeft' && n > 1) setIdx(k => (k - 1 + n) % n);
      else if (e.key === 'ArrowRight' && n > 1) setIdx(k => (k + 1) % n);
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [lightbox, n]);

  if (!n) return null;
  const i = Math.min(idx, n - 1);
  const style = { maxWidth: '100%', borderRadius: 6, display: 'block', cursor: 'zoom-in', ...imgStyle };
  const go = (d) => setIdx(k => (k + d + n) % n);
  const arrowBtn = {
    position: 'absolute', top: '50%', transform: 'translateY(-50%)',
    width: 24, height: 24, borderRadius: '50%', cursor: 'pointer', fontSize: 13, lineHeight: 1,
    background: 'rgba(0,0,0,0.55)', border: '1px solid var(--border-soft)', color: '#fff',
    display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
  };
  const lightboxArrow = {
    position: 'fixed', top: '50%', transform: 'translateY(-50%)', zIndex: 61,
    width: 40, height: 40, borderRadius: '50%', cursor: 'pointer', fontSize: 20, lineHeight: 1,
    background: 'rgba(0,0,0,0.55)', border: '1px solid rgba(255,255,255,0.25)', color: '#fff',
    display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
  };
  return (
    <>
      <div style={{ position: 'relative', marginTop: style.marginTop, marginBottom: style.marginBottom }}>
        <img src={urls[i]} alt="" loading="lazy" onClick={() => setLightbox(true)}
             style={{ ...style, marginTop: 0, marginBottom: 0 }} />
        {n > 1 && (
          <>
            <button onClick={() => go(-1)} title="Previous image" style={{ ...arrowBtn, left: 4 }}>‹</button>
            <button onClick={() => go(1)} title="Next image" style={{ ...arrowBtn, right: 4 }}>›</button>
            <span style={{
              position: 'absolute', right: 6, bottom: 6, padding: '1px 7px', borderRadius: 10,
              fontSize: 9.5, fontFamily: 'var(--font-mono)', background: 'rgba(0,0,0,0.55)', color: '#fff',
            }}>{i + 1} / {n}</span>
          </>
        )}
      </div>
      {lightbox && (
        <div onClick={() => setLightbox(false)} style={{
          position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 60,
          display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 30,
        }}>
          <img src={urls[i]} alt="" onClick={e => e.stopPropagation()}
               style={{ maxWidth: '92vw', maxHeight: '92vh', borderRadius: 4, display: 'block', cursor: 'default' }} />
          <button onClick={() => setLightbox(false)} title="Close (Esc)" style={{
            position: 'fixed', top: 18, right: 22, width: 36, height: 36, borderRadius: '50%', zIndex: 61,
            cursor: 'pointer', fontSize: 20, lineHeight: 1,
            background: 'rgba(0,0,0,0.6)', border: '1px solid rgba(255,255,255,0.25)', color: '#fff',
            display: 'flex', alignItems: 'center', justifyContent: 'center',
          }}>×</button>
          {n > 1 && (
            <>
              <button onClick={e => { e.stopPropagation(); go(-1); }} title="Previous (←)" style={{ ...lightboxArrow, left: 18 }}>‹</button>
              <button onClick={e => { e.stopPropagation(); go(1); }} title="Next (→)" style={{ ...lightboxArrow, right: 18 }}>›</button>
              <span style={{
                position: 'fixed', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 61,
                padding: '3px 10px', borderRadius: 10, fontSize: 11, fontFamily: 'var(--font-mono)',
                background: 'rgba(0,0,0,0.6)', color: '#fff',
              }}>{i + 1} / {n}</span>
            </>
          )}
        </div>
      )}
    </>
  );
}

function MessageActions({ text }) {
  const [copied, setCopied] = useS(false);
  const [translation, setTranslation] = useS(null);
  const [translating, setTranslating] = useS(false);
  const [translateErr, setTranslateErr] = useS(null);

  const btnStyle = {
    padding: '2px 7px', borderRadius: 5, cursor: 'pointer', fontSize: 9.5,
    background: 'transparent', border: '1px solid var(--border-soft)', color: 'var(--text-3)',
    fontFamily: 'var(--font-sans)', display: 'inline-flex', alignItems: 'center', gap: 3,
  };

  const onCopy = async () => {
    try {
      await navigator.clipboard.writeText(text);
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    } catch (_) {}
  };

  const onWhatsApp = () => {
    window.open('https://wa.me/?text=' + encodeURIComponent(text), '_blank', 'noopener');
  };

  const onTranslate = async () => {
    if (translation) { setTranslation(null); return; }  // toggle back to original
    setTranslating(true);
    setTranslateErr(null);
    try {
      setTranslation(await translateToPT(text));
    } catch (e) {
      setTranslateErr('Translation failed');
    }
    setTranslating(false);
  };

  return (
    <div>
      {translation && (
        <div style={{
          fontSize: 12, color: 'var(--text)', whiteSpace: 'pre-wrap', lineHeight: 1.45,
          marginBottom: 6, wordBreak: 'break-word', padding: '7px 9px',
          background: 'var(--bg-card)', borderRadius: 6, border: '1px solid var(--border-soft)',
        }}>
          <span style={{ fontSize: 9, color: 'var(--text-4)', textTransform: 'uppercase', letterSpacing: '0.06em', display: 'block', marginBottom: 4 }}>Portuguese</span>
          {translation}
        </div>
      )}
      <div style={{ display: 'flex', gap: 5 }}>
        <button onClick={onCopy} style={btnStyle} title="Copy message text">{copied ? '✓ copied' : 'copy'}</button>
        <button onClick={onTranslate} style={btnStyle} disabled={translating} title="Translate to Portuguese">
          {translating ? 'translating…' : translation ? 'show original' : 'translate PT'}
        </button>
        <button onClick={onWhatsApp} style={btnStyle} title="Send via WhatsApp">WhatsApp</button>
      </div>
      {translateErr && <div style={{ fontSize: 9.5, color: 'var(--bear)', marginTop: 3 }}>{translateErr}</div>}
    </div>
  );
}

function feedFilterQuery(symbol, query) {
  const qs = new URLSearchParams();
  if (symbol) qs.set('symbol', symbol);
  if (query) qs.set('q', query);
  return qs.toString();
}

// Earlier edit versions of a message, collapsed under its latest card. Each keeps its own
// text and sub_events, so what the trader changed — and which version was added to the
// ledger — stays visible without the message showing up once per edit.
function EditVersions({ versions, onJumpToLedger, onAddToLedger }) {
  const [open, setOpen] = useS(false);
  if (!versions || !versions.length) return null;
  return (
    <div style={{ marginTop: 8 }}>
      <button onClick={() => setOpen(o => !o)} style={{
        background: 'transparent', border: 'none', padding: 0, cursor: 'pointer',
        fontSize: 10.5, color: 'var(--text-4)', fontFamily: 'var(--font-sans)',
      }}>{open ? '▾' : '▸'} edited · {versions.length} earlier version{versions.length === 1 ? '' : 's'}</button>
      {open && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 6 }}>
          {versions.map(v => (
            <div key={v.id} style={{ padding: '8px 10px', borderRadius: 7, border: '1px dashed var(--border-soft)' }}>
              <div className="mono" style={{ fontSize: 9.5, color: 'var(--text-4)', marginBottom: 4 }}>
                version {v.edit_version}{v.processed_at ? ' · ' + String(v.processed_at).replace('T', ' ').slice(0, 16) : ''}
              </div>
              {v.text
                ? <ExpandableText text={v.text} limit={320}
                    style={{ fontSize: 11.5, color: 'var(--text-2)', whiteSpace: 'pre-wrap', lineHeight: 1.45, wordBreak: 'break-word', marginBottom: 6 }} />
                : <div style={{ fontSize: 11, color: 'var(--text-4)', marginBottom: 6 }}>no text in this version</div>}
              <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
                {(v.sub_events || []).map(s => <SubEventRow key={s.id} s={s} onJumpToLedger={onJumpToLedger} onAddToLedger={onAddToLedger} />)}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

function SignalFeed({ onJumpToLedger, onAddToLedger, strategyId, symbolFilter, queryFilter, readOnly }) {
  const [posts, setPosts] = useS([]);
  const [next, setNext] = useS(null);
  const [loading, setLoading] = useS(true);
  const [rerunBusy, setRerunBusy] = useS(null);   // post id being re-run
  const [rerunNote, setRerunNote] = useS({});     // post id -> { ok, text } from the last re-run

  // Actionable posts, plus forwards. Pure commentary lives in its own column beside this
  // one, and showing the same post in both just makes the operator read twice. A forwarded
  // message is shown here (not in the commentary column) so it is never invisible: some
  // channels, like Dr. Profit's Signals Only, are made of forwards. The pipeline still does
  // not trade on them; they carry a "Forwarded message" label.
  const NOOP = ['STATUS_COMMENTARY', 'INFO_ONLY', 'NOISE_ANNOUNCEMENT',
                'CONDITIONAL_WATCH', 'AMBIGUOUS'];

  const load = useC(async (before) => {
    const filt = feedFilterQuery(symbolFilter, queryFilter);
    const q = (before ? `?before_id=${before}&limit=25` : '?limit=25') + (filt ? '&' + filt : '');
    const r = await fetch(sigWithStrategy('/api/signals/feed' + q, strategyId)).then(x => x.json()).catch(() => null);
    if (r && r.ok) {
      const actionable = r.data.filter(p => (p.sub_events || [])
        .some(s => NOOP.indexOf(s.intent) === -1));
      setPosts(p => before ? p.concat(actionable) : actionable);
      setNext(r.next_before_id);
    }
    setLoading(false);
  }, [strategyId, symbolFilter, queryFilter]);

  useE(() => { setLoading(true); load(); const id = setInterval(() => load(), 60000); return () => clearInterval(id); }, [load]);

  // A message that failed before a fix can be sent back through the normal pipeline, as if it had
  // just arrived (checked against the market now). Only posts that produced no signal qualify.
  const FAILED = ['skipped', 'needs_approval', 'alert_user'];
  // A failed message, or a chart post nothing was made of (called commentary: the chart itself may
  // be the setup, and the pipeline now reads it).
  const SOFT = ['STATUS_COMMENTARY', 'INFO_ONLY', 'AMBIGUOUS', 'CONDITIONAL_WATCH', 'NOISE_ANNOUNCEMENT'];
  const canRerun = (p) => {
    const subs = p.sub_events || [];
    if (subs.some(x => x.signal_id != null)) return false;
    return subs.some(x => FAILED.indexOf(x.action) !== -1)
      || (!!(p.media_urls && p.media_urls.length) && subs.length > 0 && subs.every(x => SOFT.indexOf(x.intent) !== -1));
  };
  const rerun = async (p) => {
    const mins = Math.max(0, Math.round((Date.now() / 1000 - (p.date_unixtime || 0)) / 60));
    const age = mins < 120 ? `${mins} min` : `${Math.round(mins / 60)} h`;
    if (!window.confirm(`Re-run this message through the pipeline as if it had just arrived?\n\n`
      + `It is ${age} old and is checked against the market now, so a setup that has already played out is still refused. `
      + `This replaces the earlier verdict for this message.`)) return;
    setRerunBusy(p.id);
    const r = await fetch(`/api/signals/feed/post/${p.id}/reprocess`, { method: 'POST' })
      .then(x => x.json()).catch(e => ({ ok: false, reason: 'REQUEST_FAILED', detail: String(e.message || e) }));
    setRerunBusy(null);
    const text = r.ok
      ? (r.results || []).map(x => `${x.intent} ${x.symbol || '-'} → ${x.action}${x.reason ? ` (${x.reason})` : ''}`).join(' · ') || 'processed'
      : `${r.reason || 'failed'}: ${r.detail || r.error || ''}`;
    setRerunNote(n => ({ ...n, [p.id]: { ok: !!r.ok, text } }));
    load();
  };

  return (
    <div className="card" style={{ gridColumn: 'span 7' }}>
      <div className="card-h" style={{ justifyContent: 'space-between' }}>
        <span style={{ fontWeight: 600, fontSize: 13 }}>Signal feed</span>
        <span className="chip neutral" style={{ fontSize: 10 }}>{posts.length} shown</span>
      </div>
      <div className="card-b" style={{ maxHeight: '74vh', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}>
        {loading && <div style={{ padding: 26, textAlign: 'center', color: 'var(--text-4)', fontSize: 12 }}>Loading…</div>}
        {!loading && !posts.length && (
          <div style={{ padding: 26, textAlign: 'center', color: 'var(--text-4)', fontSize: 12 }}>
            {(symbolFilter || queryFilter)
              ? 'No actionable messages match this filter.'
              : 'Nothing processed yet — the worker may still be starting.'}
          </div>
        )}
        {posts.map(p => (
          <div key={p.id} style={{ padding: 10, background: 'var(--bg-elev)', borderRadius: 8, border: '1px solid var(--border-soft)' }}>
            <div style={{ display: 'flex', gap: 8, alignItems: 'baseline', marginBottom: 6 }}>
              <span className="mono" style={{ fontSize: 10, color: 'var(--text-4)' }}>#{p.primary_message_id}</span>
              {p.forwarded_from && <span className="chip" style={{ fontSize: 9.5, background: 'var(--bg-card)', color: 'var(--text-4)' }}>forwarded</span>}
              {(p.versions || []).length > 0 && <span className="chip neutral" style={{ fontSize: 9.5 }} title="The trader edited this message; earlier versions are below.">edited</span>}
              <div style={{ flex: 1 }} />
              {!readOnly && canRerun(p) && (
                <button onClick={() => rerun(p)} disabled={rerunBusy === p.id}
                        title="Send this message back through the pipeline as if it had just arrived — for messages that failed before a fix. Checked against the market now."
                        style={{ padding: '1px 8px', borderRadius: 9, cursor: rerunBusy === p.id ? 'wait' : 'pointer', fontSize: 9.5, fontWeight: 600,
                                 background: 'transparent', border: '1px solid var(--gold)', color: 'var(--gold)', fontFamily: 'var(--font-sans)' }}>
                  {rerunBusy === p.id ? 'Re-running…' : '↻ Re-run'}
                </button>
              )}
              <span className="mono" style={{ fontSize: 10, color: 'var(--text-4)' }}>
                {new Date((p.date_unixtime || 0) * 1000).toISOString().replace('T', ' ').replace(/\..+/, '')}
              </span>
            </div>
            {rerunNote[p.id] && (
              <div style={{ fontSize: 11, marginBottom: 8, padding: '5px 9px', borderRadius: 6,
                            border: '1px solid ' + (rerunNote[p.id].ok ? 'var(--border-soft)' : 'var(--bear)'),
                            color: rerunNote[p.id].ok ? 'var(--text-2)' : 'var(--bear)' }}>
                Re-run: {rerunNote[p.id].text}
              </div>
            )}
            {p.text && (
              <ExpandableText text={p.text} limit={640}
                style={{ fontSize: 12, color: 'var(--text)', whiteSpace: 'pre-wrap', lineHeight: 1.45, marginBottom: 8, wordBreak: 'break-word' }} />
            )}
            <MediaCarousel urls={p.media_urls} imgStyle={{
              maxHeight: '38vh', border: '1px solid var(--border-soft)', marginBottom: 8,
            }} />
            {p.text && <div style={{ marginBottom: 8 }}><MessageActions text={p.text} /></div>}
            <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
              {(p.sub_events || []).map(s => <SubEventRow key={s.id} s={s} onJumpToLedger={onJumpToLedger} onAddToLedger={onAddToLedger} />)}
            </div>
            <EditVersions versions={p.versions} onJumpToLedger={onJumpToLedger} onAddToLedger={onAddToLedger} />
          </div>
        ))}
        {next && (
          <button onClick={() => load(next)} style={{
            padding: '8px', borderRadius: 7, cursor: 'pointer', fontSize: 11.5,
            background: 'var(--bg-elev)', border: '1px solid var(--border-soft)',
            color: 'var(--text-2)', fontFamily: 'var(--font-sans)',
          }}>Load older</button>
        )}
      </div>
    </div>
  );
}

// ── trader commentary ──────────────────────────────────────────────────────
// Everything the trader says that ISN'T a trade instruction: market reads,
// education, banter. Sourced from the signal channel itself, not a members'
// group — other people's chat is noise, and this column exists to read the
// trader's own thinking beside their calls.
function TraderChat({ strategyId, symbolFilter, queryFilter }) {
  const [posts, setPosts] = useS([]);
  const [next, setNext] = useS(null);
  const [loading, setLoading] = useS(true);

  const COMMENTARY = ['STATUS_COMMENTARY', 'INFO_ONLY', 'NOISE_ANNOUNCEMENT',
                      'CONDITIONAL_WATCH', 'AMBIGUOUS'];

  const load = useC(async (before) => {
    const filt = feedFilterQuery(symbolFilter, queryFilter);
    const q = (before ? '?before_id=' + before + '&limit=30' : '?limit=30') + (filt ? '&' + filt : '');
    const r = await fetch(sigWithStrategy('/api/signals/feed' + q, strategyId)).then(x => x.json()).catch(() => null);
    if (r && r.ok) {
      const only = r.data.filter(p => (p.sub_events || []).length
        && (p.sub_events || []).every(s => COMMENTARY.indexOf(s.intent) !== -1));
      setPosts(p => before ? p.concat(only) : only);
      setNext(r.next_before_id);
    }
    setLoading(false);
  }, [strategyId, symbolFilter, queryFilter]);

  useE(() => { setLoading(true); load(); const id = setInterval(() => load(), 60000); return () => clearInterval(id); }, [load]);

  return (
    <div className="card" style={{ gridColumn: 'span 5' }}>
      <div className="card-h" style={{ justifyContent: 'space-between' }}>
        <span style={{ fontWeight: 600, fontSize: 13 }}>Trader commentary</span>
        <span className="chip neutral" style={{ fontSize: 10 }}>non-trade posts</span>
      </div>
      <div className="card-b" style={{ maxHeight: '74vh', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 7 }}>
        {loading && <div style={{ padding: 26, textAlign: 'center', color: 'var(--text-4)', fontSize: 12 }}>Loading…</div>}
        {!loading && !posts.length && (
          <div style={{ padding: 26, textAlign: 'center', color: 'var(--text-4)', fontSize: 12 }}>
            {(symbolFilter || queryFilter) ? 'No commentary matches this filter.' : 'No commentary posts in this range.'}
          </div>
        )}
        {posts.map(p => (
          <div key={p.id} style={{ padding: '8px 10px', background: 'var(--bg-elev)', borderRadius: 7, border: '1px solid var(--border-soft)' }}>
            <div style={{ display: 'flex', gap: 8, alignItems: 'baseline', marginBottom: 4 }}>
              <span className="mono" style={{ fontSize: 9.5, color: 'var(--text-4)' }}>#{p.primary_message_id}</span>
              {(p.sub_events || []).slice(0, 2).map(s => (
                <span key={s.id} className="chip neutral" style={{ fontSize: 9 }}>{s.intent}</span>
              ))}
              <div style={{ flex: 1 }} />
              <span className="mono" style={{ fontSize: 9.5, color: 'var(--text-4)' }}>
                {new Date((p.date_unixtime || 0) * 1000).toISOString().replace('T', ' ').slice(0, 16)}
              </span>
            </div>
            {p.text && (
              <ExpandableText text={p.text} limit={420}
                style={{ fontSize: 11.5, color: 'var(--text-2)', whiteSpace: 'pre-wrap', lineHeight: 1.45, wordBreak: 'break-word', marginBottom: 6 }} />
            )}
            <MediaCarousel urls={p.media_urls} imgStyle={{ marginTop: 6, maxHeight: '28vh', borderRadius: 5 }} />
            {p.text && <MessageActions text={p.text} />}
          </div>
        ))}
        {next && (
          <button onClick={() => load(next)} style={{
            padding: '7px', borderRadius: 7, cursor: 'pointer', fontSize: 11,
            background: 'var(--bg-elev)', border: '1px solid var(--border-soft)',
            color: 'var(--text-2)', fontFamily: 'var(--font-sans)',
          }}>Load older</button>
        )}
      </div>
    </div>
  );
}

// ── audit log ──────────────────────────────────────────────────────────────
function AuditLog({ strategyId }) {
  const [rows, setRows] = useS([]);
  const [next, setNext] = useS(null);
  const [meta, setMeta] = useS({ reasons: [], intents: [], actions: [] });
  const [f, setF] = useS({ symbol: '', intent: '', reason: '', action: '' });

  useE(() => {
    fetch('/api/signals/reasons').then(r => r.json()).then(r => { if (r.ok) setMeta(r.data); }).catch(() => {});
  }, []);

  const load = useC(async (before, filters) => {
    const ff = filters || f;
    const q = new URLSearchParams({ limit: '50' });
    Object.keys(ff).forEach(k => { if (ff[k]) q.set(k, ff[k]); });
    if (before) q.set('before_id', before);
    if (strategyId) q.set('strategy_id', strategyId);
    const r = await fetch('/api/signals/audit?' + q).then(x => x.json()).catch(() => null);
    if (r && r.ok) { setRows(p => before ? p.concat(r.data) : r.data); setNext(r.next_before_id); }
  }, [f, strategyId]);

  useE(() => { load(); }, []);

  const set = (k, v) => { const nf = { ...f, [k]: v }; setF(nf); load(null, nf); };
  const sel = {
    padding: '5px 8px', borderRadius: 6, fontSize: 11, background: 'var(--bg-elev)',
    border: '1px solid var(--border-soft)', color: 'var(--text-2)', fontFamily: 'var(--font-sans)',
  };

  return (
    <div>
      {/* filters in one row above the table */}
      <div style={{ display: 'flex', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
        <input placeholder="symbol" value={f.symbol} onChange={e => set('symbol', e.target.value)} style={{ ...sel, width: 90 }} />
        <select value={f.intent} onChange={e => set('intent', e.target.value)} style={sel}>
          <option value="">all intents</option>
          {meta.intents.map(i => <option key={i.intent} value={i.intent}>{i.intent} ({i.n})</option>)}
        </select>
        <select value={f.action} onChange={e => set('action', e.target.value)} style={sel}>
          <option value="">all actions</option>
          {meta.actions.map(a => <option key={a.action} value={a.action}>{a.action} ({a.n})</option>)}
        </select>
        <select value={f.reason} onChange={e => set('reason', e.target.value)} style={sel}>
          <option value="">all reasons</option>
          {meta.reasons.map(r => <option key={r.reason_code} value={r.reason_code} title={r.reason_code}>{reasonLabel(r.reason_code)} ({r.n})</option>)}
        </select>
        {(f.symbol || f.intent || f.action || f.reason) && (
          <button onClick={() => { const nf = { symbol: '', intent: '', reason: '', action: '' }; setF(nf); load(null, nf); }}
                  style={{ ...sel, cursor: 'pointer' }}>clear</button>
        )}
      </div>
      <div style={{ maxHeight: '54vh', overflowY: 'auto' }}>
        <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
          <thead><tr>
            {['when', 'symbol', 'intent', 'action', 'reason', 'accounts'].map(h => (
              <th key={h} style={{ textAlign: 'left', fontSize: 9.5, color: 'var(--text-4)', letterSpacing: '0.09em', textTransform: 'uppercase', padding: '0 8px 7px' }}>{h}</th>
            ))}
          </tr></thead>
          <tbody>
            {rows.map(r => {
              const tone = ACTION_TONE[r.action] || 'muted';
              const color = tone === 'bull' ? 'var(--bull)' : tone === 'info' ? 'var(--info)'
                : tone === 'gold' ? 'var(--gold)' : 'var(--text-4)';
              return (
                <tr key={r.id}>
                  <td style={{ padding: '6px 8px', borderTop: '1px solid var(--border-soft)', color: 'var(--text-4)' }}>{agoFrom(r.at)}</td>
                  <td style={{ padding: '6px 8px', borderTop: '1px solid var(--border-soft)', fontWeight: 600 }}>{r.symbol || '—'}</td>
                  <td style={{ padding: '6px 8px', borderTop: '1px solid var(--border-soft)', fontFamily: 'var(--font-mono)', color: 'var(--text-2)' }}>{r.intent}</td>
                  <td style={{ padding: '6px 8px', borderTop: '1px solid var(--border-soft)', color }}>{r.action}</td>
                  <td style={{ padding: '6px 8px', borderTop: '1px solid var(--border-soft)', fontFamily: 'var(--font-mono)', color: 'var(--text-3)' }} title={r.reason_code || ''}>{reasonLabel(r.reason_code)}</td>
                  <td style={{ padding: '6px 8px', borderTop: '1px solid var(--border-soft)', fontSize: 10.5 }}>
                    {(r.accounts || []).map(a => (
                      <div key={a.account_id} title={a.reason_code || ''} style={{ whiteSpace: 'nowrap' }}>
                        <span style={{ color: 'var(--text-3)' }}>{a.account_label}</span>{' '}
                        <span style={{ color: a.action === 'enter' ? 'var(--bull)' : a.action === 'error' ? 'var(--bear)' : 'var(--text-4)' }}>{a.action}</span>
                      </div>
                    ))}
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
        {!rows.length && <div style={{ padding: 22, textAlign: 'center', color: 'var(--text-4)', fontSize: 12 }}>No decisions match.</div>}
        {next && (
          <button onClick={() => load(next)} style={{ ...sel, cursor: 'pointer', width: '100%', marginTop: 8 }}>Load older</button>
        )}
      </div>
    </div>
  );
}

// ── page ───────────────────────────────────────────────────────────────────
const POLICY_LABEL = {
  A: ['A · bracket', 'Initial stop, everything out at target 1, stop never moved. The candidate live policy.'],
  B: ['B · trader-managed', 'Equal slices over his targets, stop to breakeven after the first. How he manages it.'],
  D: ['D · equal slices', 'Up to 4 equal slices at his targets, original stop never moved.'],
};

function Scorecard({ strategyId, viewMode }) {
  const [sc, setSc] = useS(null);
  const [scope, setScope] = useS('forward');
  useE(() => {
    const url = viewMode === 'admin'
      ? sigWithStrategy('/api/signals/scorecard', strategyId)
      : meStrategyUrl(strategyId, 'scorecard');
    const load = () => fetch(url).then(r => r.json())
      .then(j => { if (j && j.ok) setSc(j.data); }).catch(() => {});
    load();
    const id = setInterval(load, 60000);
    return () => clearInterval(id);
  }, [strategyId, viewMode]);
  if (!sc) return <div style={{ color: 'var(--text-4)', fontSize: 12 }}>Loading…</div>;

  const g = sc.groups[scope];
  const fmtR = v => v == null ? '—' : (v >= 0 ? '+' : '−') + Math.abs(v).toFixed(2) + 'R';
  const tone = v => v == null ? 'var(--text-3)' : v > 0 ? 'var(--bull)' : v < 0 ? 'var(--bear)' : 'var(--text-3)';
  const cell = { padding: '6px 10px', textAlign: 'right' };
  const rowsDef = [];
  ['A', 'B', 'D'].forEach(p => [['all', 'all signals'], ['eligible', 'eligible only']].forEach(
    ([k, lab]) => rowsDef.push({ p, k, lab, s: g[p][k] })));
  const nForward = g.A.all.n + g.A.all.open;
  const rep = Object.entries(sc.trader_reported || {}).sort((a, b) => b[1] - a[1]);

  return (
    <div>
      <div style={{ display: 'flex', gap: 4, marginBottom: 10 }}>
        {[['forward', 'Forward record'], ['history', 'History (before rules-v1)']].map(([id, label]) => (
          <button key={id} onClick={() => setScope(id)} style={{
            padding: '4px 12px', borderRadius: 6, border: 'none', cursor: 'pointer', fontSize: 11.5,
            fontFamily: 'var(--font-sans)', fontWeight: scope === id ? 600 : 400,
            background: scope === id ? 'var(--bg-elev)' : 'transparent',
            color: scope === id ? 'var(--gold)' : 'var(--text-3)',
          }}>{label}</button>
        ))}
      </div>
      {scope === 'forward' && nForward === 0 ? (
        <div style={{ color: 'var(--text-4)', fontSize: 12, padding: '10px 0' }}>
          No signals since the rules were frozen{sc.forward_since ? ' (' + sc.forward_since.slice(0, 16).replace('T', ' ') + ' UTC)' : ''}.
          The first one will appear here once it has resolved.
        </div>
      ) : (
        <div style={{ overflowX: 'auto' }}>
          <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
            <thead>
              <tr style={{ color: 'var(--text-4)', fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
                <th style={{ ...cell, textAlign: 'left' }}>Policy</th>
                <th style={cell}>Closed</th><th style={cell}>Open</th><th style={cell}>Win rate</th>
                <th style={cell}>Avg R</th><th style={cell}>Total R</th><th style={cell}>Max drawdown</th>
              </tr>
            </thead>
            <tbody>
              {rowsDef.map(({ p, k, lab, s }) => (
                <tr key={p + k} style={{ borderTop: '1px solid var(--border-soft)' }}>
                  <td style={{ ...cell, textAlign: 'left' }} title={POLICY_LABEL[p][1]}>
                    <span style={{ fontWeight: k === 'all' ? 600 : 400 }}>{POLICY_LABEL[p][0]}</span>
                    <span style={{ color: 'var(--text-4)' }}> · {lab}</span>
                  </td>
                  <td className="mono" style={cell}>{s.n}</td>
                  <td className="mono" style={{ ...cell, color: 'var(--text-4)' }}>{s.open}</td>
                  <td className="mono" style={cell}>{s.n ? Math.round(100 * s.wins / s.n) + '%' : '—'}</td>
                  <td className="mono" style={{ ...cell, color: tone(s.avg_r) }}>{fmtR(s.avg_r)}</td>
                  <td className="mono" style={{ ...cell, color: tone(s.total_r) }}>{fmtR(s.total_r)}</td>
                  <td className="mono" style={cell}>{s.max_drawdown_r == null ? '—' : s.max_drawdown_r.toFixed(2) + 'R'}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
      <div style={{ marginTop: 10, fontSize: 11, color: 'var(--text-4)', lineHeight: 1.5 }}>
        R after taker fees, measured against the risk at our fill. Outcomes come from Bybit candles, not
        from what he says; a candle touching both stop and target counts as the stop.
        {g.A.all.n < 25 && ' With fewer than ~25 closed trades this can only rule out disasters, not prove an edge.'}
        {rep.length > 0 && scope === 'history' && (
          <span> · His own account of these: {rep.map(([k, v]) => k.replace('trader_', '') + ' ' + v).join(', ')}.</span>
        )}
      </div>
    </div>
  );
}

// One trader's pipeline (admin only). strategyId scopes every fetch to that trader;
// exchange balances/equity live on each account's own page (My account).
function W_SignalsSection({ strategyId, strategyName, viewMode, userRole }) {
  return (
    <GoogleSignInGate title={viewMode === 'admin' ? 'Operator' : 'Strategy'} subtitle="Sign in to view it.">
      <W_SignalsSectionInner key={strategyId || 'all'} strategyId={strategyId} strategyName={strategyName} viewMode={viewMode || 'user'} userRole={userRole} />
    </GoogleSignInGate>
  );
}

// ── the message that opened a signal, on demand — doesn't depend on the feed's
// own pagination, so it works no matter how far the post has scrolled off ──────
function PostPreviewModal({ state, onClose, onJumpToLedger, onAddToLedger }) {
  if (!state) return null;
  return (
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 50,
      display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
    }} onClick={onClose}>
      <div onClick={e => e.stopPropagation()} className="card" style={{ width: 520, maxWidth: '100%', maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}>
        <div className="card-h" style={{ justifyContent: 'space-between' }}>
          <span style={{ fontWeight: 600, fontSize: 13 }}>Originating message</span>
          <button onClick={onClose} style={{
            background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-4)', fontSize: 16, padding: 0,
          }}>×</button>
        </div>
        <div className="card-b" style={{ overflowY: 'auto' }}>
          {state.loading && <div style={{ padding: 20, textAlign: 'center', color: 'var(--text-4)', fontSize: 12 }}>Loading…</div>}
          {state.error && <div style={{ padding: 20, textAlign: 'center', color: 'var(--bear)', fontSize: 12 }}>{state.error}</div>}
          {state.post && (
            <div>
              <div style={{ display: 'flex', gap: 8, alignItems: 'baseline', marginBottom: 8 }}>
                <span className="mono" style={{ fontSize: 10, color: 'var(--text-4)' }}>#{state.post.primary_message_id}</span>
                <span className="mono" style={{ fontSize: 10, color: 'var(--text-4)' }}>
                  {new Date((state.post.date_unixtime || 0) * 1000).toISOString().replace('T', ' ').replace(/\..+/, '')}
                </span>
              </div>
              {state.post.text && (
                <div style={{ fontSize: 12.5, color: 'var(--text)', whiteSpace: 'pre-wrap', lineHeight: 1.5, marginBottom: 10, wordBreak: 'break-word' }}>
                  {state.post.text}
                </div>
              )}
              <MediaCarousel urls={state.post.media_urls} imgStyle={{
                maxHeight: '38vh', border: '1px solid var(--border-soft)', marginBottom: 10,
              }} />
              <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
                {(state.post.sub_events || []).map(s => (
                  <SubEventRow key={s.id} s={s} onJumpToLedger={id => { onClose(); onJumpToLedger(id); }}
                    onAddToLedger={onAddToLedger ? se => { onClose(); onAddToLedger(se); } : null} />
                ))}
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

// How a signal's story ended, in one line — the deliberately explicit case is
// a silent expiry: the state/outcome columns alone don't say whether that's
// because the trader went quiet after the entry message, or after several.
function describeSignalEnding(sig, postCount) {
  if (!sig) return null;
  const state = sig.state, reason = sig.close_reason;
  if (state === 'LIVE' || state === 'PARTIAL') {
    return { text: 'Still open in the ledger — no ending yet.', tone: 'var(--text-3)' };
  }
  if (state === 'EXPIRED' && (!reason || reason === 'ttl_silent')) {
    return {
      text: (postCount > 0
        ? `Expired: the trader never posted about this again after the message${postCount > 1 ? 's' : ''} above.`
        : 'Expired: no message is linked to this signal at all, and the trader never followed up.')
        + ' Auto-closed by the silence timeout — not something he said.',
      tone: 'var(--gold)',
    };
  }
  if (state === 'CANCELLED') return { text: 'Cancelled' + (reason ? ` — ${reason}` : '') + '.', tone: 'var(--text-4)' };
  if (state === 'REJECTED') return { text: 'Rejected' + (reason ? ` — ${reason}` : '') + '.', tone: 'var(--bear)' };
  if (state === 'CLOSED') return { text: 'Closed' + (reason ? ` — ${reason}` : '') + '.', tone: 'var(--text-2)' };
  return { text: state || '—', tone: 'var(--text-4)' };
}

// ── signal timeline: every message that ever touched one signal, in order —
// answers "was there one message or more" directly instead of making someone
// search for it. The founding post plus every follow-up that produced a
// decision (partial TPs, SL moves, a close); a silent expiry simply shows
// nothing after the last one, which is itself the answer.
function TimelineModal({ state, onClose, onJumpToLedger, onAddToLedger }) {
  if (!state) return null;
  const sig = state.signal;
  const posts = state.posts || [];
  const ending = describeSignalEnding(sig, posts.length);
  return (
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 50,
      display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
    }} onClick={onClose}>
      <div onClick={e => e.stopPropagation()} className="card" style={{ width: 560, maxWidth: '100%', maxHeight: '84vh', display: 'flex', flexDirection: 'column' }}>
        <div className="card-h" style={{ justifyContent: 'space-between' }}>
          <span style={{ fontWeight: 600, fontSize: 13 }}>
            Signal timeline{sig ? ` · ${sig.symbol} ${sig.side}` : ''}
          </span>
          <button onClick={onClose} style={{
            background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-4)', fontSize: 16, padding: 0,
          }}>×</button>
        </div>
        <div className="card-b" style={{ overflowY: 'auto' }}>
          {state.loading && <div style={{ padding: 20, textAlign: 'center', color: 'var(--text-4)', fontSize: 12 }}>Loading…</div>}
          {state.error && <div style={{ padding: 20, textAlign: 'center', color: 'var(--bear)', fontSize: 12 }}>{state.error}</div>}
          {!state.loading && !state.error && (
            <div>
              <div style={{ fontSize: 10.5, color: 'var(--text-4)', marginBottom: 12 }}>
                {posts.length} message{posts.length === 1 ? '' : 's'} linked to this signal.
              </div>
              {!posts.length && (
                <div style={{ fontSize: 12, color: 'var(--text-4)', marginBottom: 4 }}>No message is linked to this signal.</div>
              )}
              <div style={{ display: 'flex', flexDirection: 'column' }}>
                {posts.map((p, i) => (
                  <div key={p.id} style={{ display: 'flex', gap: 10 }}>
                    <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 4, width: 8 }}>
                      <span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--gold)', flexShrink: 0 }} />
                      {i < posts.length - 1 && <span style={{ flex: 1, width: 1, background: 'var(--border-soft)', marginTop: 4 }} />}
                    </div>
                    <div style={{ flex: 1, minWidth: 0, paddingBottom: 14 }}>
                      <div style={{ display: 'flex', gap: 8, alignItems: 'baseline', marginBottom: 6 }}>
                        <span className="mono" style={{ fontSize: 10, color: 'var(--text-4)' }}>#{p.primary_message_id}</span>
                        <span className="mono" style={{ fontSize: 10, color: 'var(--text-4)' }}>
                          {new Date((p.date_unixtime || 0) * 1000).toISOString().replace('T', ' ').replace(/\..+/, '')}
                        </span>
                      </div>
                      {p.text && (
                        <div style={{ fontSize: 12, color: 'var(--text)', whiteSpace: 'pre-wrap', lineHeight: 1.45, marginBottom: 8, wordBreak: 'break-word' }}>
                          {p.text}
                        </div>
                      )}
                      <MediaCarousel urls={p.media_urls} imgStyle={{
                        maxHeight: '30vh', border: '1px solid var(--border-soft)', marginBottom: 8,
                      }} />
                      <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
                        {(p.sub_events || []).map(s => (
                          <SubEventRow key={s.id} s={s}
                            onJumpToLedger={onJumpToLedger ? (id => { onClose(); onJumpToLedger(id); }) : null}
                            onAddToLedger={onAddToLedger ? (se => { onClose(); onAddToLedger(se); }) : null} />
                        ))}
                      </div>
                    </div>
                  </div>
                ))}
              </div>
              {ending && (
                <div style={{
                  marginTop: 4, paddingTop: 12, borderTop: '1px solid var(--border-soft)',
                  fontSize: 11.5, color: ending.tone, lineHeight: 1.5,
                }}>
                  {ending.text}
                </div>
              )}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

const CHART_INTERVALS = [['5', '5m'], ['15', '15m'], ['60', '1h'], ['240', '4h']];

// Entry/SL/targets as chart reference lines + a %-distance list — answers "has
// price run too far from this to still be worth resending" (the Resend button
// right next to it does the same "is this still valid" check, just without a
// picture). TPs are ordered the same way the pipeline already orders them
// (nearest-first for the signal's own side), so "TP1" here means the same thing
// it means everywhere else in the app.
function levelsForSignal(sig) {
  if (!sig) return [];
  const levels = [];
  // A limit/split entry's reference_price IS entry_price (the order rests at the
  // stated price), so they only ever diverge for CMP/DCA entries — the follower
  // fills at the market price when the signal arrived, which is rarely the exact
  // number the trader posted. Showing both lines only when they actually differ
  // keeps a limit entry's chart from drawing two identical dashed lines.
  const entry = sig.entry_price, stated = sig.reference_price;
  if (entry != null && stated != null && Number(entry) !== Number(stated)) {
    levels.push({ price: Number(entry), label: 'Our Entry', color: 'var(--gold)' });
    levels.push({ price: Number(stated), label: 'Trader Entry', color: 'var(--info)' });
  } else {
    const single = entry != null ? entry : stated;
    if (single != null) levels.push({ price: Number(single), label: 'Entry', color: 'var(--gold)' });
  }
  // sl moves in place too (a tightened stop, breakeven after TP1, …) while
  // sl_initial stays write-once at whatever the founding post stated — the
  // same kind of drift as entry_price vs. reference_price, just on the other
  // side of the trade. Found on $TIA (Gauls): a breakeven move after TP1 put
  // the current SL (0.3956, pinned to OUR fill + a buffer) above the trader's
  // stated entry (0.3632), which reads as backwards until the move is visible.
  const sl = sig.sl, slInit = sig.sl_initial;
  if (sl != null && slInit != null && Number(sl) !== Number(slInit)) {
    levels.push({ price: Number(sl), label: 'SL', color: 'var(--bear)' });
    levels.push({ price: Number(slInit), label: 'Initial SL', color: 'var(--bear-dim)' });
  } else {
    const singleSl = sl != null ? sl : slInit;
    if (singleSl != null) levels.push({ price: Number(singleSl), label: 'SL', color: 'var(--bear)' });
  }
  const tps = [...(sig.tps || [])].sort((a, b) => sig.side === 'short' ? b - a : a - b);
  tps.forEach((tp, i) => levels.push({ price: Number(tp), label: `TP${i + 1}`, color: 'var(--bull)' }));
  return levels.filter(lv => Number.isFinite(lv.price));
}

function SignalChartModal({ signalId, onClose }) {
  const [interval_, setInterval_] = useS('15');
  const [state, setState] = useS({ loading: true });
  const [retry, setRetry] = useS(0);

  useE(() => {
    if (!signalId) return;
    let alive = true;
    setState({ loading: true });
    fetch(`/api/signals/${signalId}/chart?interval=${interval_}`).then(r => {
      // A proxy/gateway error (502/504 from nginx, a dropped connection, …)
      // sends an HTML error page, not JSON — r.json() on that throws "Unexpected
      // token '<'", which is accurate but meaningless to read. Catch it here with
      // the actual HTTP status instead, and say "try again": these are almost
      // always transient (seen in practice: one request landed 502 while the
      // server was otherwise healthy; retrying the exact same request worked).
      if (!r.ok) throw new Error(`Request failed (HTTP ${r.status}) — try again`);
      return r.json();
    }).then(j => {
      if (!alive) return;
      if (j.ok) setState({ loading: false, ...j.data });
      else setState({ loading: false, error: j.error || 'Could not load the chart' });
    }).catch(e => { if (alive) setState({ loading: false, error: String(e.message || e) }); });
    return () => { alive = false; };
  }, [signalId, interval_, retry]);

  if (!signalId) return null;
  const sig = state.signal;
  const mark = state.mark;
  const levels = levelsForSignal(sig);
  const entryTime = sig ? parseIso(sig.created_at) : null;
  const markers = entryTime != null
    ? [{ t: entryTime, label: 'Entry', color: 'var(--gold)' }] : [];

  return (
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 50,
      display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
    }} onClick={onClose}>
      <div onClick={e => e.stopPropagation()} className="card" style={{ width: 920, maxWidth: '100%', maxHeight: '88vh', display: 'flex', flexDirection: 'column' }}>
        <div className="card-h" style={{ justifyContent: 'space-between' }}>
          <span style={{ fontWeight: 600, fontSize: 13 }}>
            {sig ? `${sig.symbol} ${sig.side} · price vs. levels` : 'Price chart'}
          </span>
          <button onClick={onClose} style={{
            background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-4)', fontSize: 16, padding: 0,
          }}>×</button>
        </div>
        <div className="card-b" style={{ overflowY: 'auto' }}>
          {state.loading && <div style={{ padding: 26, textAlign: 'center', color: 'var(--text-4)', fontSize: 12 }}>Loading…</div>}
          {state.error && (
            <div style={{ padding: 26, textAlign: 'center' }}>
              <div style={{ color: 'var(--bear)', fontSize: 12, marginBottom: 10 }}>{state.error}</div>
              <button onClick={() => setRetry(n => n + 1)} style={{
                padding: '4px 12px', borderRadius: 6, cursor: 'pointer', fontSize: 11.5,
                fontFamily: 'var(--font-sans)', fontWeight: 600,
                background: 'transparent', border: '1px solid var(--gold)', color: 'var(--gold)',
              }}>Retry</button>
            </div>
          )}
          {!state.loading && !state.error && (
            <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
              <div style={{ flex: '2 1 480px', minWidth: 0 }}>
                <div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
                  {CHART_INTERVALS.map(([id, label]) => (
                    <button key={id} onClick={() => setInterval_(id)} style={{
                      padding: '3px 10px', borderRadius: 5, cursor: 'pointer', fontSize: 11,
                      fontFamily: 'var(--font-sans)', fontWeight: interval_ === id ? 700 : 400,
                      background: interval_ === id ? 'var(--bg-elev)' : 'transparent',
                      border: '1px solid ' + (interval_ === id ? 'var(--gold)' : 'var(--border-soft)'),
                      color: interval_ === id ? 'var(--gold)' : 'var(--text-3)',
                    }}>{label}</button>
                  ))}
                </div>
                {state.candles && state.candles.length
                  ? <PriceChart candles={state.candles} levels={levels} markers={markers} height={360} showEmas={false} showPatterns={false} />
                  : <div style={{ padding: 26, textAlign: 'center', color: 'var(--text-4)', fontSize: 12 }}>No candle history for this symbol/timeframe.</div>}
              </div>
              <div style={{ flex: '1 1 220px', minWidth: 200 }}>
                <div style={{ fontSize: 10, color: 'var(--text-4)', marginBottom: 6 }}>
                  Current <span className="mono" style={{ color: 'var(--text-2)' }}>{fmtNum(mark)}</span>
                  {sig && sig.market_price_at_post != null && (
                    <span> · at post <span className="mono" style={{ color: 'var(--text-3)' }}>{fmtNum(sig.market_price_at_post)}</span></span>
                  )}
                </div>
                {sig && sig.entry_price != null && sig.reference_price != null
                  && Number(sig.entry_price) !== Number(sig.reference_price) && (() => {
                    const gapPct = ((Number(sig.entry_price) - Number(sig.reference_price)) / Number(sig.reference_price)) * 100;
                    return (
                      <div style={{ fontSize: 10, color: 'var(--text-4)', marginBottom: 6 }}
                        title="Our fill (market price when the signal reached us) vs. the price the trader stated">
                        Entry gap <span className="mono" style={{ color: gapPct >= 0 ? 'var(--bull)' : 'var(--bear)' }}>
                          {signedPct(gapPct)}
                        </span>
                        <span> · our <span className="mono" style={{ color: 'var(--text-3)' }}>{fmtNum(sig.entry_price)}</span> vs.
                          his <span className="mono" style={{ color: 'var(--text-3)' }}>{fmtNum(sig.reference_price)}</span></span>
                      </div>
                    );
                  })()}
                <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
                  {levels.map((lv, i) => {
                    const dist = mark ? ((lv.price - mark) / mark) * 100 : null;
                    return (
                      <div key={i} style={{
                        display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
                        padding: '5px 8px', borderRadius: 6, background: 'var(--bg-elev)',
                        borderLeft: `2px solid ${lv.color}`,
                      }}>
                        <span style={{ fontSize: 11, color: lv.color, fontWeight: 600 }}>{lv.label}</span>
                        <span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{fmtNum(lv.price)}</span>
                        <span className="mono" style={{ fontSize: 10.5, color: dist == null ? 'var(--text-4)' : dist >= 0 ? 'var(--bull)' : 'var(--bear)' }}>
                          {dist == null ? '—' : signedPct(dist)}
                        </span>
                      </div>
                    );
                  })}
                  {!levels.length && (
                    <div style={{ fontSize: 11, color: 'var(--text-4)' }}>No entry/SL/targets recorded for this signal.</div>
                  )}
                </div>
                <div style={{ fontSize: 10, color: 'var(--text-4)', marginTop: 10, lineHeight: 1.5 }}>
                  % is each level's distance from the current price — a positive number
                  means price has to rise to reach it, negative means it has to fall.
                </div>
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

function W_SignalsSectionInner({ strategyId, strategyName, viewMode, userRole }) {
  const isAdmin = viewMode === 'admin';
  // Arena members read the trader's messages and the decision history, but get none of the controls.
  const canRead = isAdmin || userRole === 'arena';
  // The assistant (widgets-assistant.jsx) is for the admin and members.
  const canAsk = (userRole === 'admin' || userRole === 'arena') && typeof openAssistant === 'function' && !!strategyId;
  const askAboutTrade = canAsk ? (row) => openAssistant({
    kind: 'chat', strategy_id: strategyId, strategy_name: strategyName, signal_id: row.id,
    prompt: `Look at trade #${row.id} (${row.symbol} ${row.side}). Is it recorded correctly, and what has happened to it so far?`,
    autoSend: true,
  }) : null;
  const [health, setHealth] = useS(null);
  // null = not loaded yet, which is not the same as empty: "No open signals" before
  // the first fetch lands would read as a real, alarming answer.
  const [ledger, setLedger] = useS(null);
  const [marks, setMarks] = useS({ at: null, stale: false });
  const [ctrl, setCtrl] = useS({ control: [], audit: [] });
  const [closeTarget, setCloseTarget] = useS(null);
  const [editTarget, setEditTarget] = useS(null);
  const [editPosTarget, setEditPosTarget] = useS(null);
  const [createFromSubEvent, setCreateFromSubEvent] = useS(null);
  const [closing, setClosing] = useS(false);
  const [closeErr, setCloseErr] = useS(null);
  const [resendTarget, setResendTarget] = useS(null);
  const [resending, setResending] = useS(false);
  const [resendErr, setResendErr] = useS(null);
  const [matchedOnly, setMatchedOnly] = useS(false);
  const [includeOrphans, setIncludeOrphans] = useS(false);
  const [scorecardOpen, setScorecardOpen] = useS(false);
  const [envFilter, setEnvFilter] = useS('all');
  const [period, setPeriod] = useS('1m');
  const [customSince, setCustomSince] = useS('');
  const [customUntil, setCustomUntil] = useS('');
  const [sideFilter, setSideFilter] = useS('all');
  const [driftFilter, setDriftFilter] = useS('all');
  const [accountFilter, setAccountFilter] = useS('all');
  // Deliberately separate from period/customSince/customUntil above — those are
  // documented to never touch the open ledger (it's "right now", no lower bound
  // makes sense). This narrows the ledger TABLE itself by when a trade opened.
  const [openedSince, setOpenedSince] = useS('');
  const [openedUntil, setOpenedUntil] = useS('');
  const [resendResult, setResendResult] = useS(null);
  const [reanalyzeTarget, setReanalyzeTarget] = useS(null);
  const [reanalyzing, setReanalyzing] = useS(false);
  const [reanalyzeErr, setReanalyzeErr] = useS(null);
  const [reanalyzeResult, setReanalyzeResult] = useS(null);
  const [messagePreview, setMessagePreview] = useS(null);
  const [timelinePreview, setTimelinePreview] = useS(null);
  const [chartSignalId, setChartSignalId] = useS(null);
  const [flashSignal, setFlashSignal] = useS(null);
  const [ledgerTab, setLedgerTab] = useS('open');
  const [history, setHistory] = useS(null);
  // Set by the Realized P&L tile: show exactly these trades in History ({ ids, label }).
  const [tradeFilter, setTradeFilter] = useS(null);
  const [specSymbol, setSpecSymbol] = useS(null);
  const [feedSymbol, setFeedSymbol] = useS('');
  const [feedQuery, setFeedQuery] = useS('');
  const [feedQueryDeb, setFeedQueryDeb] = useS('');
  const [feedSymbols, setFeedSymbols] = useS([]);
  useE(() => {
    const id = setTimeout(() => setFeedQueryDeb(feedQuery.trim()), 350);
    return () => clearTimeout(id);
  }, [feedQuery]);
  useE(() => {
    setFeedSymbol('');
    if (!canRead) return;
    fetch(sigWithStrategy('/api/signals/feed/symbols', strategyId)).then(r => r.json())
      .then(r => { if (r.ok) setFeedSymbols(r.data); }).catch(() => {});
  }, [strategyId, canRead]);

  // The tile's trades belong to one period/environment, so changing either drops the filter.
  useE(() => { setTradeFilter(null); }, [strategyId, period, customSince, customUntil, envFilter]);

  const showTrades = ({ ids, label }) => {
    setHistory(null);
    setTradeFilter({ ids, label });
    setLedgerTab('history');
    const el = document.getElementById('ledger-card');
    if (el && el.scrollIntoView) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
  };
  const clearTradeFilter = () => { setHistory(null); setTradeFilter(null); };

  useE(() => {
    if (ledgerTab !== 'history' && ledgerTab !== 'stories') return;
    // The stories tab always shows every closed trade in the period, never a "show the trades behind
    // this figure" subset, which belongs to the History tab.
    const subset = ledgerTab === 'stories' ? null : tradeFilter;
    const qs = subset
      ? new URLSearchParams({ ids: subset.ids.join(','), limit: '200' })
      : new URLSearchParams({ state: 'closed', limit: '200' });
    if (subset) {
      // exact signals, any state, no period window — see /api/signals/ledger?ids=
    } else if (period === 'custom') {
      if (customSince) qs.set('since', customSince);
      if (customUntil) qs.set('until', customUntil);
    } else if (period !== 'all') {
      qs.set('period', period);
    }
    const url = isAdmin
      ? sigWithStrategy('/api/signals/ledger?' + qs.toString(), strategyId)
      : meStrategyUrl(strategyId, 'ledger') + '?' + qs.toString();
    fetch(url).then(r => r.json())
      .then(r => { if (r.ok) setHistory(r.data); }).catch(() => {});
  }, [ledgerTab, strategyId, period, customSince, customUntil, isAdmin, tradeFilter]);

  const handleDeleteHistory = async (row) => {
    if (!confirm(`Permanently delete this ${row.symbol} ${row.side} entry from history? This cannot be undone.`)) return;
    try {
      const r = await fetch(`/api/signals/${row.id}`, { method: 'DELETE' }).then(r => r.json());
      if (r.ok) setHistory(h => (h || []).filter(x => x.id !== row.id));
      else alert('Could not delete: ' + (r.detail || r.reason || r.error || 'unknown error'));
    } catch (e) {
      alert('Could not delete: ' + e.message);
    }
  };

  const ctrlMap = {};
  (ctrl.control || []).forEach(c => { ctrlMap[c.key] = c.value; });

  const loadAll = useC(async () => {
    const ledgerUrl = isAdmin
      ? openLedgerUrl(strategyId, period, customSince, customUntil, includeOrphans)
      : openLedgerUrlUser(strategyId, period, customSince, customUntil, includeOrphans);
    const fetches = [
      fetch(ledgerUrl).then(r => r.json()).catch(() => null),
    ];
    if (isAdmin) {
      fetches.push(
        fetch('/api/signals/health').then(r => r.json()).catch(() => null),
        fetch('/api/signals/control').then(r => r.json()).catch(() => null),
      );
    }
    const results = await Promise.all(fetches);
    const l = results[0];
    if (l && l.ok) { setLedger(l.data); setMarks({ at: l.marks_at, stale: !!l.marks_stale }); }
    if (isAdmin) {
      const h = results[1], c = results[2];
      if (h && h.ok) setHealth(h.data);
      if (c && c.ok) setCtrl(c.data);
    }
  }, [strategyId, period, customSince, customUntil, isAdmin, includeOrphans]);

  useE(() => { loadAll(); const id = setInterval(loadAll, 60000); return () => clearInterval(id); }, [loadAll]);
  // A fix applied from the assistant changes the ledger: show it now, not on the next tick.
  useE(() => {
    const onChanged = () => loadAll();
    window.addEventListener('ledger-changed', onChanged);
    return () => window.removeEventListener('ledger-changed', onChanged);
  }, [loadAll]);

  // The paper P&L moves with the market, so the ledger refreshes on its own faster
  // clock than the rest of the page. The server caches Bybit for a few seconds, so
  // this does not multiply upstream requests. Skipped in a background tab.
  useE(() => {
    const ledgerUrl = isAdmin
      ? openLedgerUrl(strategyId, period, customSince, customUntil, includeOrphans)
      : openLedgerUrlUser(strategyId, period, customSince, customUntil, includeOrphans);
    const tick = async () => {
      if (document.hidden) return;
      const l = await fetch(ledgerUrl).then(r => r.json()).catch(() => null);
      if (l && l.ok) { setLedger(l.data); setMarks({ at: l.marks_at, stale: !!l.marks_stale }); }
    };
    tick();
    const id = setInterval(tick, 10000);
    return () => clearInterval(id);
  }, [strategyId, period, customSince, customUntil, isAdmin, includeOrphans]);

  async function confirmClose() {
    if (!closeTarget) return;
    setClosing(true); setCloseErr(null);
    try {
      let url, body;
      if (isAdmin) {
        url = '/api/signals/close-position';
        body = JSON.stringify({ signal_id: closeTarget.row.id, account_id: closeTarget.accountId });
      } else {
        const pos = (closeTarget.row.positions || []).find(p => p.account_id === closeTarget.accountId);
        url = `/api/me/accounts/${closeTarget.accountId}/positions/${pos ? pos.id : 0}/close`;
        body = undefined;
      }
      const r = await fetch(url, {
        method: 'POST', headers: { 'Content-Type': 'application/json' }, body,
      }).then(x => x.json());
      if (!r.ok) { setCloseErr(r.error || r.reason || 'Close failed'); setClosing(false); return; }
      setCloseTarget(null); setClosing(false);
      loadAll();
    } catch (e) {
      setCloseErr(String(e.message || e)); setClosing(false);
    }
  }

  async function confirmResend() {
    if (!resendTarget) return;
    setResending(true); setResendErr(null); setResendResult(null);
    try {
      let url;
      if (isAdmin) {
        url = `/api/signals/${resendTarget.row.id}/resend`;
      } else {
        const acct = (resendTarget.row.positions || [])[0];
        const accountId = acct ? acct.account_id : 0;
        url = `/api/me/accounts/${accountId}/signals/${resendTarget.row.id}/resend`;
      }
      const r = await fetch(url, { method: 'POST' }).then(x => x.json());
      setResending(false);
      if (!r.ok) {
        setResendErr(r.detail && r.detail.reason ? `${r.reason}: ${r.detail.reason}` : (r.reason || r.error || 'Resend failed'));
        return;
      }
      setResendResult(r);
      loadAll();
    } catch (e) {
      setResendErr(String(e.message || e)); setResending(false);
    }
  }

  async function confirmReanalyze() {
    if (!reanalyzeTarget) return;
    setReanalyzing(true); setReanalyzeErr(null); setReanalyzeResult(null);
    try {
      const r = await fetch(`/api/signals/${reanalyzeTarget.row.id}/reanalyze-levels`, { method: 'POST' }).then(x => x.json());
      setReanalyzing(false);
      if (!r.ok) {
        setReanalyzeErr(r.detail || r.reason || r.error || 'Re-analyze failed');
        return;
      }
      setReanalyzeResult(r);
      if (r.changed) loadAll();
    } catch (e) {
      setReanalyzeErr(String(e.message || e)); setReanalyzing(false);
    }
  }

  function jumpToLedgerRow(signalId) {
    const el = document.getElementById('ledger-row-' + signalId);
    if (!el) { window.alert('That signal is not currently open in the ledger (closed, expired, or not yet loaded).'); return; }
    el.scrollIntoView({ behavior: 'smooth', block: 'center' });
    setFlashSignal(signalId);
    setTimeout(() => setFlashSignal(f => (f === signalId ? null : f)), 1600);
  }

  async function viewMessage(postId) {
    setMessagePreview({ loading: true });
    try {
      const r = await fetch('/api/signals/feed/post/' + postId).then(x => x.json());
      if (r.ok) setMessagePreview({ post: r.data });
      else setMessagePreview({ error: r.error || 'Could not load that message' });
    } catch (e) {
      setMessagePreview({ error: String(e.message || e) });
    }
  }

  async function viewTimeline(signalId) {
    setTimelinePreview({ loading: true });
    try {
      const r = await fetch('/api/signals/' + signalId + '/timeline').then(x => x.json());
      if (r.ok) setTimelinePreview({ signal: r.data.signal, posts: r.data.posts });
      else setTimelinePreview({ error: r.error || 'Could not load this signal\'s timeline' });
    } catch (e) {
      setTimelinePreview({ error: String(e.message || e) });
    }
  }

  async function toggleKill() {
    const cur = String(ctrlMap.kill_switch || '0') === '1';
    try {
      const r = await fetch('/api/signals/control', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ key: 'kill_switch', value: cur ? '0' : '1' }),
      }).then(x => x.json());
      if (!r.ok) { window.alert('Kill switch did not update: ' + (r.error || 'unknown error')); return; }
    } catch (e) {
      window.alert('Kill switch did not update: ' + String(e.message || e));
      return;
    }
    loadAll();
  }

  const grid = { display: 'grid', gridTemplateColumns: 'repeat(12, 1fr)', gap: 14 };
  // Independent of the other filters below, so the dropdown's own option list
  // stays stable as the user toggles them — it would be a confusing UX if
  // picking an account hid other accounts from the very picker that offers them.
  const accountOptions = [];
  if (ledger) {
    const seen = new Set();
    ledger.forEach(r => (r.positions || []).forEach(p => {
      if (seen.has(p.account_id)) return;
      seen.add(p.account_id);
      accountOptions.push({ id: p.account_id, label: p.account_label });
    }));
    accountOptions.sort((a, b) => String(a.label).localeCompare(String(b.label)));
  }
  const filteredLedger = ledger ? filterLedgerRows(ledger, matchedOnly, envFilter, includeOrphans, {
    sideFilter, driftFilter, accountFilter, openedSince, openedUntil,
  }) : null;

  return (
    <div>
      <TopFilterBar matchedOnly={matchedOnly} setMatchedOnly={setMatchedOnly}
        envFilter={envFilter} setEnvFilter={setEnvFilter}
        period={period} setPeriod={setPeriod}
        customSince={customSince} setCustomSince={setCustomSince}
        customUntil={customUntil} setCustomUntil={setCustomUntil} viewMode={viewMode}
        includeOrphans={includeOrphans} setIncludeOrphans={setIncludeOrphans}
        sideFilter={sideFilter} setSideFilter={setSideFilter}
        driftFilter={driftFilter} setDriftFilter={setDriftFilter}
        accountFilter={accountFilter} setAccountFilter={setAccountFilter}
        accountOptions={accountOptions}
        openedSince={openedSince} setOpenedSince={setOpenedSince}
        openedUntil={openedUntil} setOpenedUntil={setOpenedUntil} />
      {isAdmin && <HealthStrip health={health} control={ctrlMap} onKill={toggleKill} />}
      <StrategyPnlRow strategyId={strategyId} filteredLedger={filteredLedger} envFilter={envFilter}
        period={period} customSince={customSince} customUntil={customUntil} marksStale={marks.stale} viewMode={viewMode}
        onShowTrades={showTrades} />
      <div className="rg-12" style={grid}>
        <div id="ledger-card" className="card" style={{ gridColumn: 'span 12' }}>
          <div className="card-h" style={{ justifyContent: 'space-between' }}>
            <span style={{ display: 'flex', gap: 4 }}>
              {[['open', isAdmin ? 'Ledger ↔ accounts' : 'Active trades'], ['history', 'History'], ['stories', 'Trade stories'], ['report', 'Report']].map(([id, label]) => (
                <button key={id} onClick={() => setLedgerTab(id)} style={{
                  padding: '4px 12px', borderRadius: 6, border: 'none', cursor: 'pointer',
                  fontSize: 13, fontFamily: 'var(--font-sans)', fontWeight: ledgerTab === id ? 600 : 400,
                  background: ledgerTab === id ? 'var(--bg-elev)' : 'transparent',
                  color: ledgerTab === id ? 'var(--text)' : 'var(--text-3)',
                }}>{label}</button>
              ))}
            </span>
            {canAsk && (ledgerTab === 'history' ? (
              <button onClick={() => openAssistant({ kind: 'review_history', strategy_id: strategyId, strategy_name: strategyName,
                                                     prompt: `Review ${strategyName || 'this'} trade history: find finished trades recorded wrong (close price or time, stops the trader moved, expiries) and suggest fixes.`, autoSend: true })}
                className="as-ask-btn" style={{ marginLeft: 'auto', marginRight: 8 }}
                title="The assistant checks finished trades against the trader's messages and the price, and suggests fixes">
                ✦ Review history
              </button>
            ) : (
              <button onClick={() => openAssistant({ kind: 'review', strategy_id: strategyId, strategy_name: strategyName,
                                                     prompt: `Review ${strategyName || 'this'} ledger: find anything recorded wrong and suggest fixes.`, autoSend: true })}
                className="as-ask-btn" style={{ marginLeft: 'auto', marginRight: 8 }}
                title="The assistant checks this ledger against the trader's messages and prices, and suggests fixes">
                ✦ Review ledger
              </button>
            ))}
            {ledgerTab === 'open' ? (
              <span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
                {marks.at && (
                  <span className="chip neutral" style={{
                    fontSize: 10, color: marks.stale ? 'var(--gold)' : undefined,
                  }} title={marks.stale
                    ? 'Bybit did not answer just now; showing the last prices we had.'
                    : 'Live Bybit mark prices, refreshed every 10 seconds.'}>
                    {marks.stale ? '⚠ prices ' : 'prices '}{agoFrom(new Date(marks.at).toISOString())}
                  </span>
                )}
                <span className="chip neutral" style={{ fontSize: 10 }}>{ledger ? ledger.length : '…'} open</span>
              </span>
            ) : ledgerTab === 'history' ? (
              <span className="chip neutral" style={{ fontSize: 10 }}>{history ? history.length : '…'} shown</span>
            ) : null}
          </div>
          <div className="card-b">
            {ledgerTab === 'open' ? (
              <LedgerTable rows={ledger} filtered={filteredLedger || []} flashRow={flashSignal}
                onRequestClose={t => { setCloseTarget(t); setCloseErr(null); }}
                onEditPosition={setEditPosTarget}
                onViewMessage={canRead ? viewMessage : null} onEdit={setEditTarget}
                onResend={t => { setResendTarget(t); setResendErr(null); setResendResult(null); }}
                onReanalyze={isAdmin ? t => { setReanalyzeTarget(t); setReanalyzeErr(null); setReanalyzeResult(null); } : null}
                onViewChart={isAdmin ? setChartSignalId : null}
                viewMode={viewMode} onOpenSpec={setSpecSymbol} userRole={userRole} onAsk={askAboutTrade} />
            ) : ledgerTab === 'history' ? (<>
              {tradeFilter && (
                <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', padding: '8px 12px', marginBottom: 12,
                              background: 'var(--gold-bg)', border: '1px solid var(--gold)', borderRadius: 7, fontSize: 12 }}>
                  <span>
                    Showing the {history ? history.length : tradeFilter.ids.length} trade{(history ? history.length : tradeFilter.ids.length) === 1 ? '' : 's'} behind <b>{tradeFilter.label}</b>
                    {history && history.length < tradeFilter.ids.length && (
                      <span style={{ color: 'var(--text-3)' }}> ({tradeFilter.ids.length - history.length} no longer on file)</span>
                    )}
                  </span>
                  <span style={{ flex: 1 }} />
                  <button onClick={clearTradeFilter} style={{ padding: '3px 10px', borderRadius: 5, cursor: 'pointer', fontSize: 11,
                          background: 'transparent', border: '1px solid var(--gold)', color: 'var(--gold)', fontFamily: 'var(--font-sans)', fontWeight: 600 }}>
                    Show all history ✕
                  </button>
                </div>
              )}
              <HistoryTable rows={history} onViewMessage={canRead ? viewMessage : null}
                onViewTimeline={canRead ? viewTimeline : null}
                viewMode={viewMode} onOpenSpec={setSpecSymbol}
                onDelete={isAdmin ? handleDeleteHistory : null} />
            </>) : ledgerTab === 'stories' ? (
              <TradeStoriesTable open={ledger ? filteredLedger : null} closed={history} onOpenSpec={setSpecSymbol} />
            ) : (
              <ReportTab strategyId={strategyId} period={period} customSince={customSince} customUntil={customUntil}
                envFilter={envFilter} viewMode={viewMode} />
            )}
          </div>
        </div>
        <ConfirmCloseModal target={closeTarget} busy={closing} error={closeErr}
          onCancel={() => { if (!closing) setCloseTarget(null); }} onConfirm={confirmClose} />
        <EditSignalModal target={editTarget} onClose={() => setEditTarget(null)} onSaved={loadAll} viewMode={viewMode} />
        <EditPositionModal target={editPosTarget} onClose={() => setEditPosTarget(null)} onSaved={loadAll} viewMode={viewMode} />
        <ConfirmResendModal target={resendTarget} busy={resending} error={resendErr} result={resendResult}
          onCancel={() => { if (!resending) { setResendTarget(null); if (resendResult) loadAll(); } }}
          onConfirm={confirmResend} />
        <ConfirmReanalyzeModal target={reanalyzeTarget} busy={reanalyzing} error={reanalyzeErr} result={reanalyzeResult}
          onCancel={() => { if (!reanalyzing) { setReanalyzeTarget(null); if (reanalyzeResult) loadAll(); } }}
          onConfirm={confirmReanalyze} />
        {specSymbol && <SpecModal symbol={specSymbol} onClose={() => setSpecSymbol(null)} />}
        {canRead && <PostPreviewModal state={messagePreview} onClose={() => setMessagePreview(null)}
          onJumpToLedger={jumpToLedgerRow} onAddToLedger={isAdmin ? setCreateFromSubEvent : null} />}
        {canRead && <TimelineModal state={timelinePreview} onClose={() => setTimelinePreview(null)}
          onJumpToLedger={jumpToLedgerRow} onAddToLedger={isAdmin ? setCreateFromSubEvent : null} />}
        <SignalChartModal signalId={chartSignalId} onClose={() => setChartSignalId(null)} />
        {isAdmin && <CreateSignalModal target={createFromSubEvent}
          onClose={() => setCreateFromSubEvent(null)}
          onSaved={() => { setCreateFromSubEvent(null); loadAll(); }} />}

        <div className="card" style={{ gridColumn: 'span 12' }}>
          <div className="card-h" style={{ justifyContent: 'space-between', cursor: 'pointer' }} onClick={() => setScorecardOpen(v => !v)}>
            <span style={{ fontWeight: 600, fontSize: 13 }}>Scorecard · how signals ended</span>
            <span style={{ color: 'var(--text-4)', fontSize: 11 }}>{scorecardOpen ? '▲ hide' : '▼ show'}</span>
          </div>
          {scorecardOpen && <div className="card-b"><Scorecard strategyId={strategyId} viewMode={viewMode} /></div>}
        </div>

        {canRead && (
          <div className="card" style={{ gridColumn: 'span 12' }}>
            <div className="card-b" style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', padding: '10px 14px' }}>
              <span style={{ fontSize: 10.5, color: 'var(--text-4)' }} title="Filters both panels below — the actionable feed and the commentary column — by every message the trader posted, not just what made the ledger.">
                Search this trader's messages
              </span>
              <select value={feedSymbol} onChange={e => setFeedSymbol(e.target.value)} style={{
                padding: '5px 9px', borderRadius: 6, fontSize: 11.5, width: 130, cursor: 'pointer',
                fontFamily: 'var(--font-mono)', background: 'var(--bg-elev)',
                border: '1px solid var(--border-soft)', color: 'var(--text)',
              }}>
                <option value="">All coins</option>
                {feedSymbols.map(s => <option key={s} value={s}>{s}</option>)}
              </select>
              <input value={feedQuery} onChange={e => setFeedQuery(e.target.value)} placeholder="Search text…" style={{
                padding: '5px 9px', borderRadius: 6, fontSize: 11.5, flex: 1, minWidth: 160,
                fontFamily: 'var(--font-sans)', background: 'var(--bg-elev)',
                border: '1px solid var(--border-soft)', color: 'var(--text)',
              }} />
              {(feedSymbol || feedQuery) && (
                <button onClick={() => { setFeedSymbol(''); setFeedQuery(''); }} style={{
                  padding: '5px 10px', borderRadius: 6, cursor: 'pointer', fontSize: 11,
                  background: 'transparent', border: '1px solid var(--border-soft)', color: 'var(--text-3)',
                  fontFamily: 'var(--font-sans)',
                }}>Clear</button>
              )}
            </div>
          </div>
        )}
        {canRead && <SignalFeed onJumpToLedger={jumpToLedgerRow} onAddToLedger={isAdmin ? setCreateFromSubEvent : null} strategyId={strategyId}
          symbolFilter={feedSymbol} queryFilter={feedQueryDeb} readOnly={!isAdmin} />}
        {canRead && <TraderChat strategyId={strategyId} symbolFilter={feedSymbol} queryFilter={feedQueryDeb} />}

        {canRead && <div className="card" style={{ gridColumn: 'span 12' }}>
          <div className="card-h"><span style={{ fontWeight: 600, fontSize: 13 }}>Decision audit</span></div>
          <div className="card-b"><AuditLog strategyId={strategyId} /></div>
        </div>}
      </div>
    </div>
  );
}
