/* Main app shell — sidebar + section routing + API data fetching */

const { useState, useEffect, useCallback, useRef } = React;

const SECTIONS = [
  { id: 'overview',  label: 'Overview',           icon: 'grid',   kbd: '1', group: 'Home'     },
  { id: 'guide',     label: 'Guide',               icon: 'compass', kbd: '', group: 'Home'     },
  { id: 'ta',        label: 'Technical analysis',  icon: 'chart',  kbd: '2', group: 'Research' },
  { id: 'lca',       label: 'Liquidity & flow',    icon: 'layers', kbd: '3', group: 'Research' },
  { id: 'onchain',   label: 'On-chain',            icon: 'link',   kbd: '4', group: 'Research' },
  { id: 'macro',     label: 'Macro',               icon: 'globe',  kbd: '5', group: 'Research' },
  { id: 'sentiment', label: 'Sentiment',           icon: 'eye',    kbd: '6', group: 'Research' },
  { id: 'cycles',    label: 'Cycles · halving',    icon: 'cycle',  kbd: '7', group: 'Research' },
  { id: '__traders' },
  { id: 'reports',   label: 'Reports',             icon: 'report', kbd: '',  group: 'Reports', roles: ['admin'] },
  { id: 'scan',      label: 'AI Scan',             icon: 'scan',   kbd: '9', group: 'AI' },
  { id: 'telegram',  label: 'Telegram feed',       icon: 'send',   kbd: '',  group: 'Feeds', roles: ['admin', 'arena'] },
  { id: 'users',     label: 'Users & accounts',    icon: 'users',  kbd: '',  group: 'Admin', roles: ['admin'] },
  { id: 'account',   label: 'My account',          icon: 'user',   kbd: '',  group: 'Account' },
  { id: 'compare',   label: 'Compare strategies',  icon: 'chart',  kbd: '',  group: 'Account', roles: ['user', 'admin'] },
];

// Strategy (investor): #/trader/<slug> — Operator (admin): #/trader/<slug>/ops.
// The old #/ai still redirects to the first strategy.
const TRADER_PREFIX = 'trader/';
const TRADER_HASH_RE = /^trader\/([a-z0-9-]+)(\/ops)?$/;

function parseTraderHash(id) {
  const m = (id || '').match(TRADER_HASH_RE);
  if (!m) return null;
  return { slug: m[1], ops: !!m[2], baseId: TRADER_PREFIX + m[1] };
}

// Reports: #/reports (first page) or #/reports/<page> — one sidebar row, the
// pages are a sub-nav inside it (see widgets-reports.jsx's REPORT_PAGES).
const REPORTS_HASH_RE = /^reports(?:\/([a-z0-9-]+))?$/;

function parseReportsHash(id) {
  const m = (id || '').match(REPORTS_HASH_RE);
  return m ? { page: m[1] || null } : null;
}

// The URL slug for a trader page. Deliberately NOT strategies.slug (the real
// trader's name, e.g. "trader-gauls" — that's an internal identifier tied to
// config.yaml's chat mapping and stays out of any URL). Derived from public_name
// instead, which every viewer already sees ("Strategy A" -> "strategy-a") — same
// for admin and user, so one link works regardless of who opens it.
function publicSlug(name) {
  return String(name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'strategy';
}

function traderSections(strategies, role) {
  // Admin sidebar id is Operator (#/trader/<slug>/ops); user id stays Strategy.
  // baseId always trader/<slug> for active matching + Strategy toggle. slug here
  // is publicSlug(public_name), not strategies.slug — see publicSlug's own doc
  // comment: the real trader name never belongs in a URL, for any viewer.
  // Labels keep public_name so the nav entry matches the investor surface.
  // Traders first, then SignalShade's own bots (kind 'house') under their own heading.
  const list = strategies || [];
  const kindOf = (st) => (st.kind === 'house' ? 'house' : 'trader');
  const ordered = ['trader', 'house'].flatMap(k => list.filter(st => kindOf(st) === k));
  return ordered.map((st, i) => {
    const baseId = TRADER_PREFIX + publicSlug(st.public_name);
    const kind = kindOf(st);
    return {
      id: role === 'admin' ? baseId + '/ops' : baseId,
      baseId,
      label: st.public_name || st.name,
      icon: kind === 'house' ? 'bot' : 'zap', kbd: i === 0 ? '8' : '',
      group: kind === 'house' ? 'SignalShade strategies' : 'Strategies',
      roles: ['admin', 'user'], strategy: st,
    };
  });
}

// Sections without `roles` are public (market data, and My account, which hosts
// sign-in). The server enforces the same rules — this only hides what would 403.
function sectionsFor(user, strategies) {
  const out = [];
  SECTIONS.forEach(s => {
    if (s.id === '__traders') out.push(...traderSections(strategies, user && user.role));
    else out.push(s);
  });
  // An arena member is a user with extra read access, so every user section is theirs too.
  return out.filter(s => !s.roles || (user && (s.roles.includes(user.role) || (user.role === 'arena' && s.roles.includes('user')))));
}

const ROLE_LABEL = { admin: 'Admin', user: 'Free tier', arena: 'Arena member', pending: 'Pending approval' };

// One-line blurb per Research tab — shared by SectionIntro's page subtitle and
// the Overview landing page's research cards, so the copy can't drift apart.
const SECTION_BLURBS = {
  ta:        'EMAs, RSI, MACD and market structure on the daily',
  lca:       'Funding, open interest and long/short positioning',
  onchain:   'MVRV, dominance, stablecoins and holder behaviour',
  macro:     'DXY, rates, inflation and the Fed calendar',
  sentiment: 'Fear & Greed, positioning and cohort flows',
  cycles:    'Halving cycle, seasonality and long-term structure',
};

const DEFAULT_STRIP = [
  { sym: 'BTC',   px: '—',   chg: '+0.00%', up: true },
  { sym: 'ETH',   px: '—',   chg: '+0.00%', up: true },
  { sym: 'SOL',   px: '—',   chg: '+0.00%', up: true },
  { sym: 'DXY',   px: '—',   chg: '-0.00%', up: false },
  { sym: 'GOLD',  px: '—',   chg: '+0.00%', up: true },
  { sym: 'SPX',   px: '—',   chg: '+0.00%', up: true },
  { sym: 'VIX',   px: '—',   chg: '-0.00%', up: false },
  { sym: 'US10Y', px: '—',   chg: '—',      up: false },
  { sym: 'BTC.D', px: '—',   chg: '+0.00%', up: true },
];

// === Icons ================================================================
function Icon({ name, size = 14 }) {
  const s = size;
  const common = { width: s, height: s, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' };
  switch (name) {
    case 'grid':   return <svg {...common}><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>;
    case 'chart':  return <svg {...common}><path d="M3 20h18"/><path d="M5 16l4-6 4 3 6-8"/></svg>;
    case 'layers': return <svg {...common}><path d="M12 3l9 5-9 5-9-5 9-5z"/><path d="M3 13l9 5 9-5"/><path d="M3 17l9 5 9-5"/></svg>;
    case 'link':   return <svg {...common}><path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 1 0-5.66-5.66l-1 1"/><path d="M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 1 0 5.66 5.66l1-1"/></svg>;
    case 'globe':  return <svg {...common}><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18"/><path d="M12 3a14 14 0 0 0 0 18"/></svg>;
    case 'eye':    return <svg {...common}><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>;
    case 'cycle':  return <svg {...common}><path d="M3 12a9 9 0 0 1 15-6.7l3-3"/><path d="M21 12a9 9 0 0 1-15 6.7l-3 3"/><path d="M18 2v4h-4M6 22v-4h4"/></svg>;
    case 'search': return <svg {...common}><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>;
    case 'bell':   return <svg {...common}><path d="M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></svg>;
    case 'star':   return <svg {...common}><polygon points="12 2 15 9 22 10 17 15 18 22 12 19 6 22 7 15 2 10 9 9"/></svg>;
    case 'gear':   return <svg {...common}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8 1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>;
    case 'plus':   return <svg {...common}><path d="M12 5v14M5 12h14"/></svg>;
    case 'refresh':return <svg {...common}><path d="M23 4v6h-6"/><path d="M20.5 15a9 9 0 1 1-2.8-9.9L23 10"/></svg>;
    case 'zap':    return <svg {...common}><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>;
    case 'bot':    return <svg {...common}><rect x="3" y="9" width="18" height="12" rx="2"/><path d="M9 9V7a3 3 0 0 1 6 0v2"/><circle cx="9" cy="15" r="1" fill="currentColor" stroke="none"/><circle cx="15" cy="15" r="1" fill="currentColor" stroke="none"/><path d="M9 18h6"/></svg>;
    case 'send':   return <svg {...common}><path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4 20-7z"/></svg>;
    case 'scan':   return <svg {...common}><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/><path d="M11 8v6M8 11h6"/></svg>;
    case 'user':   return <svg {...common}><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>;
    case 'users':  return <svg {...common}><circle cx="9" cy="8" r="3.5"/><path d="M2 20a7 7 0 0 1 14 0"/><path d="M16 4.5a3.5 3.5 0 0 1 0 7"/><path d="M18 13.5a7 7 0 0 1 4 6.5"/></svg>;
    case 'compass': return <svg {...common}><circle cx="12" cy="12" r="9"/><path d="M15.5 8.5l-2 5-5 2 2-5 5-2z"/></svg>;
    case 'report': return <svg {...common}><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/><path d="M8 17v-3M12 17v-6M16 17v-2"/></svg>;
  }
  return null;
}

// True on phone/small-tablet widths — keep in step with the 820px breakpoint in index.html.
function useIsMobile() {
  const q = '(max-width: 820px)';
  const [m, setM] = useState(() => window.matchMedia(q).matches);
  useEffect(() => {
    const mq = window.matchMedia(q);
    const on = () => setM(mq.matches);
    mq.addEventListener('change', on);
    return () => mq.removeEventListener('change', on);
  }, []);
  return m;
}

// === Sidebar ==============================================================
function Sidebar({ active, onChange, user, sections, onCloseNav }) {
  const [collapsedPref, setCollapsed] = useState(false);
  const isMobile = useIsMobile();
  const collapsed = collapsedPref && !isMobile; // the phone drawer is always the full sidebar
  const [menuOpen, setMenuOpen] = useState(false);
  const [livePnl, setLivePnl] = useState({});
  const gearRef = useRef(null);
  const menuRef = useRef(null);

  // `user` is our own role lookup (/api/auth/me) and comes back null both when
  // Clerk has no session AND when Clerk has a valid session but the local users
  // row has no role ("row deleted out from under an active session" — see
  // server.js). That second case left someone with an active Clerk session and
  // no way to reach it: the menu only ever showed "Sign in" when `user` was
  // null, so there was no path to Clerk.signOut() short of the browser console.
  // Tracked independently so "Log out" shows whenever Clerk itself thinks
  // there's a session, regardless of whether our own role lookup succeeded.
  const [clerkHasSession, setClerkHasSession] = useState(!!(window.Clerk && window.Clerk.session));
  useEffect(() => {
    if (!window.Clerk) return undefined;
    let unsubscribe;
    let cancelled = false;
    window.Clerk.load().then(() => {
      if (cancelled) return;
      setClerkHasSession(!!window.Clerk.session);
      unsubscribe = window.Clerk.addListener(({ session }) => setClerkHasSession(!!session));
    }).catch(() => {});
    return () => { cancelled = true; if (unsubscribe) unsubscribe(); };
  }, []);

  // Sidebar's own live % chip next to each strategy — polls independently of
  // whichever page is open, since the nav is always mounted. Same 5s Bybit
  // cache as every other marks consumer (src/marks.js), so this adds no real
  // load; paused off-tab like every other poller in the app.
  const canSeeStrategies = !!(user && (user.role === 'admin' || user.role === 'user' || user.role === 'arena'));
  useEffect(() => {
    if (!canSeeStrategies) { setLivePnl({}); return undefined; }
    let alive = true;
    const fetchNow = () => {
      fetch('/api/strategies/live-pnl', { credentials: 'include' }).then(r => r.json())
        .then(j => {
          if (!alive || !j.ok) return;
          const map = {};
          j.data.forEach(d => { map[d.strategy_id] = d; });
          setLivePnl(map);
        }).catch(() => {});
    };
    fetchNow();
    const id = setInterval(() => { if (!document.hidden) fetchNow(); }, 15000);
    return () => { alive = false; clearInterval(id); };
  }, [canSeeStrategies]);

  // Unread badge on each strategy link, for THIS user. Having a strategy's page open counts as
  // reading it: the marker is moved when it opens and on every poll while it stays open, so
  // what arrives under your eyes never piles up as unread, and the badge is gone the moment
  // you open the page. Other strategies keep counting from where you last left them.
  const [unread, setUnread] = useState({});
  const openStrategy = (sections.find(s => s.id === active) || {}).strategy;
  const openStrategyId = openStrategy ? openStrategy.id : null;
  useEffect(() => {
    if (!canSeeStrategies) { setUnread({}); return undefined; }
    let alive = true;
    const tick = async () => {
      try {
        if (openStrategyId != null) {
          setUnread(u => ({ ...u, [openStrategyId]: 0 }));
          await fetch(`/api/strategies/${openStrategyId}/read`, { method: 'POST', credentials: 'include' });
        }
        const j = await fetch('/api/strategies/unread', { credentials: 'include' }).then(r => r.json());
        if (!alive || !j.ok) return;
        const map = {};
        j.data.forEach(d => { map[d.strategy_id] = d.strategy_id === openStrategyId ? 0 : d.unread; });
        setUnread(map);
      } catch (_) {}
    };
    tick();
    const id = setInterval(() => { if (!document.hidden) tick(); }, 15000);
    return () => { alive = false; clearInterval(id); };
  }, [canSeeStrategies, openStrategyId]);

  // Close the account menu on outside click / Escape
  useEffect(() => {
    if (!menuOpen) return undefined;
    function onDocClick(e) {
      if (gearRef.current && gearRef.current.contains(e.target)) return;
      if (menuRef.current && menuRef.current.contains(e.target)) return;
      setMenuOpen(false);
    }
    function onKey(e) { if (e.key === 'Escape') setMenuOpen(false); }
    document.addEventListener('mousedown', onDocClick);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDocClick);
      document.removeEventListener('keydown', onKey);
    };
  }, [menuOpen]);

  // Clerk owns the session cookie entirely — signing out is a client-side
  // Clerk call, not a server round-trip (there's no /api/auth/logout any more).
  // Clerk.load() must be awaited first: calling signOut() before clerk-js has
  // finished initializing silently no-ops (confirmed live — the session cookie
  // survives and the next reload comes back signed in). load() resolves
  // immediately if already loaded, so this is never redundant work once
  // widgets-auth.jsx's own useClerkReady got there first.
  const handleLogout = useCallback(async () => {
    try {
      if (window.Clerk) {
        await window.Clerk.load();
        await window.Clerk.signOut();
      }
    } catch (_) {}
    window.location.reload();
  }, []);

  const displayName = (user && (user.name || user.email)) || 'Guest';

  return (
    <aside className="sidebar" data-collapsed={collapsed} style={{
      background: 'var(--bg-app)',
      borderRight: '1px solid var(--border-soft)',
      display: 'flex', flexDirection: 'column',
      position: 'sticky', top: 0, height: '100vh',
      overflow: 'hidden',
    }}>
      {/* Header */}
      <div style={{ padding: collapsed ? '18px 12px 16px' : '18px 18px 16px', display: 'flex', alignItems: 'center', gap: 10, minHeight: 62 }}>
        {collapsed ? (
          <button onClick={() => setCollapsed(false)} title="Expand sidebar" style={{
            width: 28, height: 28, borderRadius: 7, flexShrink: 0, padding: 0,
            background: 'transparent', border: 'none', cursor: 'pointer', display: 'block',
          }}><img src="/icon-192.png" alt="SignalShade" width="28" height="28" style={{ display: 'block', borderRadius: 7 }} /></button>
        ) : (
          <>
            <img src="/icon-192.png" alt="SignalShade" width="28" height="28" style={{ flexShrink: 0, borderRadius: 7 }} />
            <div style={{ flex: 1, minWidth: 0 }}>
              <div style={{ fontSize: 14, fontWeight: 600, letterSpacing: '-0.01em' }}>SignalShade</div>
              <div style={{ fontSize: 9.5, color: 'var(--text-4)', letterSpacing: '0.12em', textTransform: 'uppercase' }}>crypto terminal</div>
            </div>
            <button onClick={() => (isMobile ? onCloseNav && onCloseNav() : setCollapsed(true))} title={isMobile ? 'Close menu' : 'Collapse sidebar'} style={{
              background: 'transparent', border: 'none', cursor: 'pointer',
              color: 'var(--text-4)', padding: '4px 2px', display: 'flex', alignItems: 'center', flexShrink: 0,
              fontSize: 16, lineHeight: 1,
            }}>‹</button>
          </>
        )}
      </div>

      <nav style={{ padding: collapsed ? '0 6px' : '0 8px', display: 'flex', flexDirection: 'column', gap: 1, overflowY: 'auto' }}>
        {sections.reduce((acc, s, i) => {
          const isActive = s.id === active;
          const newGroup = i === 0 || sections[i - 1].group !== s.group;
          if (!collapsed && newGroup) {
            acc.push(
              <div key={'g-' + s.group} style={{
                fontSize: 9.5, color: 'var(--text-4)', letterSpacing: '0.12em',
                textTransform: 'uppercase', padding: i === 0 ? '8px 12px 6px' : '14px 12px 6px',
              }}>{s.group}</div>
            );
          }
          acc.push(
            // The keyboard shortcut (s.kbd) lives in the tooltip only — a visible
            // "1", "2"… next to each row read as a list number, not a hotkey.
            <button key={s.id} onClick={() => onChange(s.id)} className="nav-item"
              title={[collapsed ? s.label : null, s.kbd ? `Shortcut: ${s.kbd}` : null].filter(Boolean).join(' · ') || undefined} style={{
              display: 'flex', alignItems: 'center', gap: collapsed ? 0 : 10,
              padding: collapsed ? '10px 0' : '7px 12px', borderRadius: 6,
              background: isActive ? 'var(--bg-card)' : 'transparent',
              color: isActive ? 'var(--text)' : 'var(--text-2)',
              border: 'none', cursor: 'pointer', textAlign: 'left',
              fontSize: 12.5, fontFamily: 'var(--font-sans)', fontWeight: isActive ? 500 : 400,
              justifyContent: collapsed ? 'center' : undefined, position: 'relative',
            }}>
              {isActive && !collapsed && <span className="active-bar" />}
              <span style={{ color: isActive ? 'var(--gold)' : 'var(--text-3)', display: 'flex' }}><Icon name={s.icon} /></span>
              {!collapsed && <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.label}</span>}
              {!collapsed && s.strategy && livePnl[s.strategy.id] && livePnl[s.strategy.id].avg_pct != null && (
                <span title={`Live, unrealised paper % — average move across ${livePnl[s.strategy.id].n_open} open signal${livePnl[s.strategy.id].n_open === 1 ? '' : 's'}, marked to Bybit`}
                      style={{ display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0 }}>
                  <span className="pulse" style={{ width: 5, height: 5 }} />
                  <span className="mono" style={{ fontSize: 10.5, fontWeight: 600, color: pnlColor(livePnl[s.strategy.id].avg_pct) }}>
                    {signedPct(livePnl[s.strategy.id].avg_pct)}
                  </span>
                </span>
              )}
              {s.strategy && unread[s.strategy.id] > 0 && (
                collapsed
                  ? <span title={`${unread[s.strategy.id]} unread`} style={{ position: 'absolute', top: 6, right: 8, width: 8, height: 8, borderRadius: '50%', background: 'var(--gold)' }} />
                  : <span title={`${unread[s.strategy.id]} unread since you last opened this page`} className="mono" style={{
                      flexShrink: 0, minWidth: 18, height: 18, padding: '0 6px', borderRadius: 9,
                      background: 'var(--gold)', color: 'var(--bg-base)', fontSize: 10.5, fontWeight: 700,
                      display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                    }}>{unread[s.strategy.id] > 99 ? '99+' : unread[s.strategy.id]}</span>
              )}
            </button>
          );
          return acc;
        }, [])}
      </nav>

      <div style={{ marginTop: 'auto', padding: collapsed ? '10px 6px' : '10px 14px', borderTop: '1px solid var(--border-soft)', display: 'flex', alignItems: 'center', gap: 10, justifyContent: collapsed ? 'center' : undefined }}>
        <div style={{
          width: 26, height: 26, borderRadius: '50%', flexShrink: 0, overflow: 'hidden',
          background: user && user.picture ? 'var(--bg-elev)' : 'linear-gradient(135deg, var(--info), var(--violet))',
          display: 'grid', placeItems: 'center', fontWeight: 600, fontSize: 11, color: 'var(--bg-base)'
        }}>
          {user && user.picture
            ? <img src={user.picture} alt={displayName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
            : displayName.charAt(0).toUpperCase()}
        </div>
        {!collapsed && (
          <>
            <div style={{ flex: 1, fontSize: 11, minWidth: 0 }}>
              <div style={{ fontWeight: 500, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{displayName}</div>
              <div style={{ color: 'var(--text-4)', fontSize: 10 }}>{user ? ROLE_LABEL[user.role] : 'Not signed in'}</div>
            </div>
            <div style={{ position: 'relative' }}>
              <button
                ref={gearRef}
                onClick={() => setMenuOpen(v => !v)}
                title="Account menu"
                aria-label="Account menu"
                style={{
                  background: menuOpen ? 'var(--bg-elev)' : 'transparent', border: 'none',
                  color: 'var(--text-3)', cursor: 'pointer', display: 'flex',
                  borderRadius: 6, padding: 4,
                }}
              >
                <Icon name="gear" size={14} />
              </button>
              {menuOpen && (
                <div ref={menuRef} style={{
                  position: 'absolute', bottom: 'calc(100% + 8px)', right: 0,
                  minWidth: 172, background: 'var(--bg-card)', border: '1px solid var(--border-soft)',
                  borderRadius: 8, boxShadow: '0 10px 30px oklch(0.05 0.005 248 / 0.5)',
                  padding: 6, zIndex: 20,
                }}>
                  <div style={{ padding: '6px 8px 8px', borderBottom: '1px solid var(--border-soft)', marginBottom: 4 }}>
                    <div style={{ fontSize: 11.5, fontWeight: 500, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{displayName}</div>
                    {user && user.email && (
                      <div style={{ fontSize: 10, color: 'var(--text-4)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.email}</div>
                    )}
                  </div>
                  {(user || clerkHasSession) ? (
                    <button
                      onClick={handleLogout}
                      title={!user ? 'Signed in with Clerk, but no account role was found here — sign out and try again' : undefined}
                      style={{
                        width: '100%', textAlign: 'left', background: 'transparent', border: 'none',
                        color: 'var(--bear)', fontSize: 11.5, padding: '6px 8px', borderRadius: 6, cursor: 'pointer',
                      }}
                      onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg-elev)'; }}
                      onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
                    >Log out</button>
                  ) : (
                    <button
                      onClick={() => { setMenuOpen(false); onChange('account'); }}
                      style={{
                        width: '100%', textAlign: 'left', background: 'transparent', border: 'none',
                        color: 'var(--text-2)', fontSize: 11.5, padding: '6px 8px', borderRadius: 6, cursor: 'pointer',
                      }}
                      onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg-elev)'; }}
                      onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
                    >Sign in with Google →</button>
                  )}
                </div>
              )}
            </div>
          </>
        )}
      </div>
    </aside>
  );
}

// === Top bar ==============================================================
function TopBar({ sectionMeta, strip, onRefresh, refreshing, viewMode, isTrader, onOpenNav, user }) {
  return (
    <div style={{
      position: 'sticky', top: 0, zIndex: 10,
      background: 'oklch(0.18 0.012 248 / 0.92)',
      backdropFilter: 'blur(8px)',
      borderBottom: '1px solid var(--border-soft)'
    }}>
      <div className="topbar-row" style={{ display: 'flex', alignItems: 'center', gap: 16, padding: '12px 22px', borderBottom: '1px solid var(--border-soft)' }}>
        <button className="mobile-only" onClick={onOpenNav} aria-label="Open menu" style={{
          background: 'var(--bg-card)', border: '1px solid var(--border-soft)', color: 'var(--text-2)',
          borderRadius: 6, width: 34, height: 34, alignItems: 'center', justifyContent: 'center',
          fontSize: 17, lineHeight: 1, cursor: 'pointer', flexShrink: 0,
        }}>☰</button>
        <div>
          <div style={{ fontSize: 9.5, color: 'var(--text-4)', letterSpacing: '0.12em', textTransform: 'uppercase' }}>SignalShade</div>
          <div style={{ fontSize: 16, fontWeight: 600, marginTop: 1, letterSpacing: '-0.01em' }}>{sectionMeta.label}</div>
        </div>
        {isTrader ? (
          <div className="chip" style={{
            marginLeft: 4,
            background: viewMode === 'admin' ? 'oklch(0.55 0.13 60 / 0.18)' : 'var(--bg-elev)',
            border: '1px solid ' + (viewMode === 'admin' ? 'var(--gold)' : 'var(--border-soft)'),
            color: viewMode === 'admin' ? 'var(--gold)' : 'var(--text-2)',
            fontWeight: 600,
          }} title={viewMode === 'admin'
            ? 'Operator — pipeline health, feed, record edits, kill-switch'
            : 'Strategy — investor view: your account P&L, open trades and history'}>
            {viewMode === 'admin' ? 'Operator' : 'Strategy'}
          </div>
        ) : (
          <div className="chip neutral" style={{ marginLeft: 4 }}>
            Markets
          </div>
        )}
        <div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
          <span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}><Clock /></span>
          <AssistantButton user={user} />
          <button
            onClick={onRefresh}
            title="Refresh data"
            style={{
              display: 'flex', alignItems: 'center', gap: 6,
              background: 'var(--bg-card)', border: '1px solid var(--border-soft)',
              color: 'var(--text-2)', borderRadius: 6, padding: '6px 10px',
              fontSize: 11.5, cursor: 'pointer', fontFamily: 'var(--font-sans)',
              opacity: refreshing ? 0.5 : 1,
            }}>
            <Icon name="refresh" size={13} /> Refresh
          </button>
        </div>
      </div>

      <div className="topbar-strip" style={{ display: 'flex', gap: 0, overflow: 'hidden', padding: '8px 22px', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
        {strip.map((t, i) => (
          <span key={i} style={{
            display: 'flex', alignItems: 'baseline', gap: 6,
            paddingRight: 22,
            borderRight: i < strip.length - 1 ? '1px solid var(--border-soft)' : 'none',
            paddingLeft: i > 0 ? 22 : 0
          }}>
            <span style={{ color: 'var(--text-3)', letterSpacing: '0.04em' }}>{t.sym}</span>
            <span style={{ color: 'var(--text)' }}>{t.px}</span>
            <span style={{ color: t.up ? 'var(--bull)' : 'var(--bear)' }}>{t.chg}</span>
          </span>
        ))}
      </div>
    </div>
  );
}

function Clock() {
  const [t, setT] = useState(new Date());
  useEffect(() => { const id = setInterval(() => setT(new Date()), 1000); return () => clearInterval(id); }, []);
  const pad = n => String(n).padStart(2, '0');
  return <>{pad(t.getHours())}:{pad(t.getMinutes())}:{pad(t.getSeconds())} <span style={{ color: 'var(--text-4)' }}>· UTC{-(t.getTimezoneOffset() / 60)}</span></>;
}

// === Section heading ======================================================
const _last = (arr) => (Array.isArray(arr) && arr.length ? arr[arr.length - 1] : null);

// Derive the four market biases from live snapshot data (no hardcoded calls).
// Simple, transparent heuristics — each pill also shows the metric driving it.
function computeBiases(m) {
  if (!m) return [];

  // TA — EMA stack + MACD histogram
  const px = m.last && m.last.c, e50 = _last(m.EMA50), e200 = _last(m.EMA200);
  const hist = _last(m.MACD && m.MACD.hist);
  let ta = 'neutral', taNote = 'awaiting data';
  if (px && e50 && e200) {
    if (px > e50 && e50 > e200) { ta = 'bull'; taNote = 'price > EMA50 > EMA200'; }
    else if (px < e50 && e50 < e200) { ta = 'bear'; taNote = 'price < EMA50 < EMA200'; }
    else { ta = 'neutral'; taNote = 'price vs EMAs mixed'; }
    if (hist != null) taNote += ' · MACD ' + (hist >= 0 ? '+' : '−');
  }

  // Liquidity — funding rate + long/short crowding
  const fund = _last(m.FUNDING);
  const ls = m.LONG_SHORT || {}, longs = ls.longs, shorts = ls.shorts;
  let liqScore = 0;
  if (fund != null) liqScore += fund > 0.01 ? -1 : fund < -0.005 ? 1 : 0;
  if (longs != null) liqScore += longs > 55 ? -1 : longs < 45 ? 1 : 0;
  const liq = liqScore > 0 ? 'bull' : liqScore < 0 ? 'bear' : 'neutral';
  const liqNote = 'funding ' + (fund != null ? fund.toFixed(3) + '%' : '—')
    + (longs != null ? ` · L/S ${longs.toFixed(0)}/${shorts.toFixed(0)}` : '');

  // On-chain — MVRV zone
  const mvrv = m.ONCHAIN && m.ONCHAIN.mvrv;
  let oc = 'neutral', ocNote = 'MVRV —';
  if (mvrv != null) {
    oc = mvrv > 3.5 ? 'bear' : mvrv < 1.5 ? 'bull' : 'neutral';
    ocNote = 'MVRV ' + mvrv.toFixed(2) + (m.ONCHAIN.mvrvZone ? ' · ' + m.ONCHAIN.mvrvZone : '');
  }

  // Macro — DXY 30d trend (inverse to BTC)
  const dxy = m.DXY_CHG_30D;
  let mScore = 0;
  if (dxy != null) mScore += dxy > 0.5 ? -1 : dxy < -0.5 ? 1 : 0;
  const macro = mScore > 0 ? 'bull' : mScore < 0 ? 'bear' : 'neutral';
  const macroNote = 'DXY ' + (dxy != null ? window.fmtSigned(dxy, 2) + '% (30d)' : '—');

  return [
    { label: 'TA',        bias: ta,    note: taNote },
    { label: 'Liquidity', bias: liq,   note: liqNote },
    { label: 'On-chain',  bias: oc,    note: ocNote },
    { label: 'Macro',     bias: macro, note: macroNote },
  ];
}

function SectionIntro({ section, meta, user, viewMode, onNavigate }) {
  const st = meta && meta.strategy;
  const isOperator = viewMode === 'admin';
  const isAdminUser = !!(user && user.role === 'admin');
  // Strategy (investor) copy uses public_name. Operator copy uses the internal
  // trader name + pipeline ribbon. A regular user's strategy object comes from
  // /api/strategies/list, which deliberately carries only {id, slug, public_name,
  // active} — no real trader name / follower count — so the Strategy branch must
  // never require st.name or st.followers. Followers card lives on Operator.
  const data = st ? (isOperator ? {
    title: st.name,
    sub: st.kind === 'house' ? 'SignalShade bot, paper only. ' + (st.description || '') : 'Operator — pipeline trust, feed, ledger record edits'
      + (st.followers != null
        ? ` · ${st.followers} following account${st.followers === 1 ? '' : 's'}`
        : '')
      + (st.public_name ? ` · shown to investors as "${st.public_name}"` : ''),
  } : {
    title: st.public_name || st.name,
    sub: st.kind === 'house'
      ? 'A SignalShade strategy: a BTC paper-trading bot run by this site, not a trader. ' + (st.description || '')
      : 'Strategy — your account P&L, open trades and history for this strategy',
  }) : {
    overview:  { title: "Welcome",                   sub: "Bitcoin right now: market pressure, the expected move over the next 24 hours and a plain-English market read, with the research sections below" },
    ta:        { title: "Technical analysis",        sub: SECTION_BLURBS.ta },
    lca:       { title: "Liquidity & flow",          sub: SECTION_BLURBS.lca },
    onchain:   { title: "On-chain analysis",         sub: SECTION_BLURBS.onchain },
    macro:     { title: "Macro environment",         sub: SECTION_BLURBS.macro },
    sentiment: { title: "Sentiment & positioning",   sub: SECTION_BLURBS.sentiment },
    cycles:    { title: "Cycles & seasonality",      sub: SECTION_BLURBS.cycles },
    telegram:  { title: "Telegram feed",             sub: "Live messages read from the configured group" },
    scan:      { title: "AI Scan",                   sub: "Ad-hoc chart analysis for any ticker — pick a symbol, choose a timeframe, run" },
    users:     { title: "Users & accounts",          sub: "Approve new sign-ins, see every exchange account, rename strategies" },
    account:   { title: "My account",                sub: "Your exchange accounts, settings and trade history" },
    compare:   { title: "Compare strategies",         sub: "Win rate and R-multiple performance, side by side" },
    reports:   { title: "Reports",                   sub: "Cross-strategy analysis of how each setup reached us, and what that cost" },
  }[section];

  if (!data) return null;

  const modeToggle = st && isAdminUser ? (
    <div style={{
      position: 'sticky', top: 8, zIndex: 4, display: 'inline-flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end',
    }}>
      <div style={{
        display: 'inline-flex', gap: 2, padding: 3, borderRadius: 8,
        border: '1px solid var(--border-soft)', background: 'var(--bg-card)',
      }} role="group" aria-label="Strategy or Operator mode">
        <button type="button" onClick={() => onNavigate && onNavigate(meta.baseId || meta.id)}
          title="Investor view — account P&L, open trades and history. Manual chip is read-only."
          style={{
            padding: '5px 12px', borderRadius: 6, border: 'none', cursor: 'pointer', fontSize: 12,
            fontFamily: 'var(--font-sans)', fontWeight: !isOperator ? 600 : 400,
            background: !isOperator ? 'var(--bg-elev)' : 'transparent',
            color: !isOperator ? 'var(--text)' : 'var(--text-3)',
          }}>Strategy</button>
        <button type="button" onClick={() => onNavigate && onNavigate((meta.baseId || meta.id) + '/ops')}
          title="Operator — pipeline health, feed, record edits, kill-switch, followers"
          style={{
            padding: '5px 12px', borderRadius: 6, border: 'none', cursor: 'pointer', fontSize: 12,
            fontFamily: 'var(--font-sans)', fontWeight: isOperator ? 600 : 400,
            background: isOperator ? 'var(--bg-elev)' : 'transparent',
            color: isOperator ? 'var(--gold)' : 'var(--text-3)',
          }}>Operator</button>
      </div>
      <div style={{ fontSize: 10.5, color: 'var(--text-4)', letterSpacing: '0.02em' }}>
        {isOperator ? 'Pipeline & controls' : 'Investor view'}
      </div>
    </div>
  ) : null;

  const notifyToggle = st ? <NotifyToggle strategyId={st.id} /> : null;

  return (
    <div className="rg-split" style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 20, alignItems: 'center', marginBottom: 14 }}>
      <div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
          <h1 style={{ margin: 0, fontSize: 26, fontWeight: 600, letterSpacing: '-0.01em' }}>{data.title}</h1>
          {st && (
            <span className="chip" style={{
              fontSize: 10, fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase',
              background: isOperator ? 'oklch(0.55 0.13 60 / 0.18)' : 'var(--bg-elev)',
              border: '1px solid ' + (isOperator ? 'var(--gold)' : 'var(--border-soft)'),
              color: isOperator ? 'var(--gold)' : 'var(--text-3)',
            }}>{isOperator ? 'Operator' : 'Strategy'}</span>
          )}
        </div>
        <div style={{ color: 'var(--text-3)', fontSize: 13, marginTop: 4 }}>{data.sub}</div>
      </div>
      {(modeToggle || notifyToggle) ? (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'flex-end' }}>
          {modeToggle}
          {notifyToggle}
        </div>
      ) : (!st && !['telegram', 'scan', 'users', 'account', 'compare', 'reports'].includes(section) && (
        <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
          {computeBiases(window.MOCK).map(p => (
            <BiasPill key={p.label} label={p.label} bias={p.bias} note={p.note} />
          ))}
        </div>
      ))}
    </div>
  );
}

// Per-strategy push notification preference — self-contained (fetches and posts
// its own state), so it drops into any strategy page's header with just an id.
// Default (no row in notification_strategy_prefs yet) is "on" for a strategy the
// viewer's account(s) follow, "off" otherwise — computed server-side, see
// GET /api/push/prefs. The checkbox always reflects and sets the EFFECTIVE value,
// never a tri-state "unset".
function NotifyToggle({ strategyId }) {
  const [state, setState] = useState(null); // null = loading
  const [busy, setBusy] = useState(false);

  useEffect(() => {
    let alive = true;
    Promise.all([
      fetch('/api/push/prefs', { credentials: 'include' }).then(r => r.json()).catch(() => ({ ok: false })),
      window.profitPush ? window.profitPush.status().catch(() => null) : Promise.resolve(null),
    ]).then(([prefsRes, pushStatus]) => {
      if (!alive) return;
      const row = prefsRes.ok && (prefsRes.data || []).find(r => r.strategy_id === strategyId);
      setState({ enabled: row ? row.enabled : true, pushOn: !!(pushStatus && pushStatus.subscribed) });
    });
    return () => { alive = false; };
  }, [strategyId]);

  if (!state) return null;

  async function toggle() {
    const next = !state.enabled;
    setBusy(true);
    setState(s => ({ ...s, enabled: next })); // optimistic; reverted below on failure
    try {
      const r = await fetch('/api/push/prefs', {
        method: 'POST', credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ strategy_id: strategyId, enabled: next }),
      }).then(res => res.json());
      if (!r.ok) throw new Error(r.error || 'failed to save');
    } catch (_) {
      setState(s => ({ ...s, enabled: !next }));
    } finally {
      setBusy(false);
    }
  }

  return (
    <label title={state.pushOn
        ? "Get a browser notification for this strategy's order fills, edits and closes."
        : 'Browser notifications are off — turn them on in My account first; this still saves your preference for when you do.'}
      style={{
        display: 'inline-flex', alignItems: 'center', gap: 6,
        cursor: busy ? 'default' : 'pointer', userSelect: 'none',
        fontSize: 11.5, color: state.pushOn ? 'var(--text-2)' : 'var(--text-4)',
        opacity: busy ? 0.6 : 1,
      }}>
      <input type="checkbox" checked={state.enabled} disabled={busy} onChange={toggle}
        style={{ cursor: busy ? 'default' : 'pointer', margin: 0 }} />
      Notify me
    </label>
  );
}

function BiasPill({ label, bias, note }) {
  const color = bias === 'bull' ? 'var(--bull)' : bias === 'bear' ? 'var(--bear)' : 'var(--text-3)';
  const bg    = bias === 'bull' ? 'var(--bull-bg)' : bias === 'bear' ? 'var(--bear-bg)' : 'var(--bg-card)';
  return (
    <div style={{ padding: '6px 10px', background: bg, border: '1px solid var(--border-soft)', borderRadius: 7, minWidth: 130 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 9.5, color: 'var(--text-4)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
        {label} <span style={{ marginLeft: 'auto', color, fontWeight: 700 }}>{bias.toUpperCase()}</span>
      </div>
      <div style={{ fontSize: 11, color: 'var(--text-2)', marginTop: 2 }}>{note}</div>
    </div>
  );
}

// === Overview landing ======================================================
// A clickable jump-off point, not another data widget: every card here points
// at a page that already owns this content (a strategy's own page, or one of
// the Research tabs) rather than re-rendering it — see the Overview section's
// git history for why (it used to just union all six Research tabs' widgets
// onto one page with no curation).
function NavCard({ icon, label, blurb, onClick }) {
  return (
    <div className="card hov" onClick={onClick} style={{ cursor: 'pointer' }}>
      <div className="card-b" style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
        <span style={{ color: 'var(--gold)', display: 'flex', marginTop: 1, flexShrink: 0 }}><Icon name={icon} size={18} /></span>
        <div style={{ minWidth: 0 }}>
          <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text)' }}>{label}</div>
          <div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 3, lineHeight: 1.4 }}>{blurb}</div>
        </div>
        <span style={{ marginLeft: 'auto', color: 'var(--text-4)', fontSize: 14, flexShrink: 0 }}>→</span>
      </div>
    </div>
  );
}

function CardRow({ title, children }) {
  return (
    <div>
      <div style={{ fontSize: 11, color: 'var(--text-4)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>
        {title}
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 12 }}>
        {children}
      </div>
    </div>
  );
}

function W_OverviewLanding({ sections, onNavigate }) {
  const strategies = (sections || []).filter(s => s.strategy);
  const research = SECTIONS.filter(s => s.group === 'Research');

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 22, marginTop: 18 }}>
      {/* Nobody following anything yet (new, pending or signed out): point them at the guide. */}
      {strategies.length === 0 && (
        <CardRow title="New here?">
          <NavCard icon="compass" label="Guide"
                   blurb="A 5-minute tour: what SignalShade does, connecting an exchange, following a strategy"
                   onClick={() => onNavigate('guide')} />
        </CardRow>
      )}
      {strategies.length > 0 && (
        <CardRow title="Your strategies">
          {strategies.map(s => (
            <NavCard key={s.id} icon={s.icon} label={s.label}
                     blurb="Signals, ledger and performance for this strategy"
                     onClick={() => onNavigate(s.baseId || s.id)} />
          ))}
        </CardRow>
      )}
      <CardRow title="Research">
        {research.map(s => (
          <NavCard key={s.id} icon={s.icon} label={s.label}
                   blurb={SECTION_BLURBS[s.id]}
                   onClick={() => onNavigate(s.id)} />
        ))}
      </CardRow>
    </div>
  );
}

// === Sections =============================================================
function Section({ id, route, user, meta, viewMode, sections, onNavigate }) {
  const grid = { display: 'grid', gridTemplateColumns: 'repeat(12, 1fr)', gap: 14 };
  if (meta && meta.strategy) {
    const page = <W_SignalsSection strategyId={meta.strategy.id} strategyName={meta.label} viewMode={viewMode || 'user'} userRole={user && user.role} />;
    // SignalShade bots: the luck scoreboard sits above the usual strategy page
    return meta.strategy.kind === 'house' ? <><W_HouseScoreboard currentId={meta.strategy.id} />{page}</> : page;
  }
  if (id === 'overview')  return <><div className="ov-top"><div className="ov-main"><div className="rg-12" style={grid}><W_BTCHero /></div></div><div className="ov-news-wrap"><W_NewsFeed /></div></div><div className="rg-12" style={{ ...grid, marginTop: 14 }}><W_PressureScore /><W_NextDay /><W_Evidence /><W_MarketRead /></div><W_OverviewLanding sections={sections} onNavigate={onNavigate} /></>;
  if (id === 'ta')        return <div className="rg-12" style={grid}><W_BTCHero /><W_EMAStack /><W_RSI /><W_MACD /></div>;
  if (id === 'lca')       return <div className="rg-12" style={grid}><W_Funding /><W_OpenInterest /><W_LongShort /></div>;
  if (id === 'onchain')   return <div className="rg-12" style={grid}><W_MVRV /><W_Stablecoin /><W_Dominance /><W_Whales /></div>;
  if (id === 'macro')     return <div className="rg-12" style={grid}><W_Calendar /><W_DXY /><W_Inflation /><W_FOMC /></div>;
  if (id === 'sentiment') return <div className="rg-12" style={grid}><W_FearGreed /><W_LongShort /><W_Funding /><W_Whales /></div>;
  if (id === 'cycles')    return <div className="rg-12" style={grid}><W_Halving /><W_Wyckoff /><W_Elliott /><W_MVRV /><W_Seasonality /><W_Dominance /></div>;
  if (id === 'telegram')  return <W_TelegramFeed />;
  if (id === 'scan')      return <W_AdhocSection user={user} />;
  if (id === 'users')     return <W_UsersAdmin />;
  if (id === 'account')   return <W_AccountSection user={user} />;
  if (id === 'compare')   return <W_StrategyCompare />;
  if (id === 'guide')     return <W_GettingStarted user={user} onNavigate={onNavigate} />;
  if (id === 'reports')   return <W_ReportsSection page={(parseReportsHash(route) || {}).page} onNavigate={onNavigate} />;
  return null;
}

// === Loading screen =======================================================
function LoadingScreen({ error, onRetry }) {
  return (
    <div style={{
      position: 'fixed', inset: 0,
      background: 'var(--bg-base)',
      display: 'grid', placeItems: 'center',
    }}>
      <div style={{ textAlign: 'center', fontFamily: 'var(--font-mono)' }}>
        <img src="/loading.gif" alt="SignalShade" width="96" height="96"
             style={{ display: 'block', margin: '0 auto 18px', borderRadius: 18 }} />
        {error ? (
          <>
            <div style={{ fontSize: 13, color: 'var(--bear)', marginBottom: 8 }}>Failed to load data</div>
            <div style={{ fontSize: 11, color: 'var(--text-4)', marginBottom: 16, maxWidth: 280 }}>{error}</div>
            <button onClick={onRetry} style={{
              padding: '8px 18px', background: 'var(--gold)', border: 'none',
              borderRadius: 6, color: 'var(--bg-base)', fontWeight: 600,
              cursor: 'pointer', fontFamily: 'var(--font-mono)', fontSize: 12
            }}>Retry</button>
          </>
        ) : (
          <>
            <div style={{ fontSize: 13, color: 'var(--text-3)', marginBottom: 6 }}>SignalShade</div>
            <div style={{ fontSize: 11, color: 'var(--text-4)', letterSpacing: '0.1em' }}>fetching live data…</div>
            <div style={{ marginTop: 14, display: 'flex', gap: 6, justifyContent: 'center' }}>
              {[0, 1, 2].map(i => (
                <span key={i} style={{
                  width: 6, height: 6, borderRadius: '50%',
                  background: 'var(--gold)',
                  animation: `dotBlink 1.2s ${i * 0.2}s infinite ease-in-out`,
                  display: 'inline-block',
                }} />
              ))}
            </div>
            <style>{`@keyframes dotBlink { 0%,80%,100%{opacity:0.2} 40%{opacity:1} }`}</style>
          </>
        )}
      </div>
    </div>
  );
}

// === App ==================================================================
const LIVE_PRICE_INTERVAL = 10 * 1000; // 10 s — Binance /ticker/price, 1 weight, safe at any interval

const SECTION_IDS = SECTIONS.map(s => s.id);

// Derive the active section from the URL hash so every page is shareable/bookmarkable.
// Accepts "#/ta" or "#ta", Strategy "#/trader/<slug>", Operator "#/trader/<slug>/ops",
// a report page "#/reports/<page>" (plus the old "#/ai"); falls back to overview for
// empty/unknown hashes. Non-admin /ops is redirected to Strategy in App once auth has answered.
function sectionFromHash() {
  const id = (window.location.hash || '').replace(/^#\/?/, '');
  if (id === 'ai' || parseTraderHash(id) || parseReportsHash(id)) return id;
  return SECTION_IDS.includes(id) ? id : 'overview';
}

function App() {
  const [section,    setSection]    = useState(sectionFromHash);
  const [user,       setUser]       = useState(null);
  const [userLoaded, setUserLoaded] = useState(false);
  const [strategies, setStrategies] = useState(null);
  const [loaded,     setLoaded]     = useState(false);
  const [error,      setError]      = useState(null);
  const [refreshing, setRefreshing] = useState(false);
  const [strip,      setStrip]      = useState(DEFAULT_STRIP);
  const [livePrice,  setLivePrice]  = useState(null);

  // Navigate by changing the hash — the hashchange listener below is the single
  // source of truth that pushes it back into `section` state.
  const [navOpen, setNavOpen] = useState(false);
  const navigate = useCallback((id) => { window.location.hash = '/' + id; setNavOpen(false); }, []);

  // Keep `section` in sync with the URL hash (direct links, back/forward, manual edits).
  useEffect(() => {
    const onHash = () => setSection(sectionFromHash());
    window.addEventListener('hashchange', onHash);
    // Give a bare load a real, shareable URL without adding a history entry.
    if (!window.location.hash) window.location.replace('#/' + section);
    return () => window.removeEventListener('hashchange', onHash);
  }, []);

  // Signed-in user (with role). Re-fetched when a sign-in completes anywhere on the page.
  useEffect(() => {
    const fetchUser = () => fetch('/api/auth/me', { credentials: 'include' }).then(r => r.json())
      .then(j => setUser(j.ok ? j.user : null))
      .catch(() => setUser(null))
      .finally(() => setUserLoaded(true));
    fetchUser();
    window.addEventListener('auth-changed', fetchUser);
    return () => window.removeEventListener('auth-changed', fetchUser);
  }, []);

  const isAdmin = !!(user && user.role === 'admin');
  const isUser = !!(user && (user.role === 'user' || user.role === 'arena'));
  useEffect(() => {
    if (!isAdmin && !isUser) { setStrategies([]); return; }
    const url = isAdmin ? '/api/admin/strategies' : '/api/strategies/list';
    fetch(url, { credentials: 'include' }).then(r => r.json())
      .then(j => setStrategies(j.ok ? j.data.filter(s => s.active) : []))
      .catch(() => setStrategies([]));
  }, [isAdmin, isUser]);

  // Non-admin (or signed-out) hitting #/trader/<slug>/ops → Strategy route.
  useEffect(() => {
    if (!userLoaded) return;
    const t = parseTraderHash(section);
    if (t && t.ops && !(user && user.role === 'admin')) {
      window.location.replace('#/' + t.baseId);
    }
  }, [section, user, userLoaded]);

  const visible = React.useMemo(() => sectionsFor(user, strategies), [user, strategies]);

  // Keyboard shortcuts
  useEffect(() => {
    const onKey = e => {
      if (e.target && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
      const s = visible.find(s => s.kbd === e.key);
      if (s && !e.metaKey && !e.ctrlKey) navigate(s.id);
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [navigate, visible]);

  // Full snapshot fetch — populates window.MOCK
  const fetchSnapshot = useCallback(async () => {
    setRefreshing(true);
    try {
      const res  = await fetch('/api/snapshot');
      const json = await res.json();
      if (!json.ok) throw new Error(json.error || 'Server error');
      window.MOCK = json.data;
      setLoaded(true);
      setError(null);
    } catch (err) {
      setError(err.message);
    } finally {
      setRefreshing(false);
    }
  }, []);

  // Tickers fetch — updates strip and watchlist
  const fetchTickers = useCallback(async () => {
    try {
      const res  = await fetch('/api/tickers');
      const json = await res.json();
      if (!json.ok) return;
      setStrip(json.data.strip);
    } catch (_) {}
  }, []);

  // Live price — patches the last candle close so chart feels alive
  const fetchLivePrice = useCallback(async () => {
    try {
      const res  = await fetch('/api/price');
      const json = await res.json();
      if (!json.ok || !window.MOCK) return;
      const price = json.price;
      window.MOCK.last.c       = price;
      window.MOCK.dayChange    = price - window.MOCK.prev.c;
      window.MOCK.dayChangePct = (window.MOCK.dayChange / window.MOCK.prev.c) * 100;
      // Also update the last candle in the array so the chart bar reflects it
      const c = window.MOCK.CANDLES;
      if (c && c.length) {
        c[c.length - 1].c = price;
        c[c.length - 1].h = Math.max(c[c.length - 1].h, price);
        c[c.length - 1].l = Math.min(c[c.length - 1].l, price);
      }
      setLivePrice(price); // triggers re-render so widgets pick up new window.MOCK values
    } catch (_) {}
  }, []);

  // Initial load
  useEffect(() => { fetchSnapshot(); }, []);

  // Refresh snapshot every 15 min
  useEffect(() => {
    const id = setInterval(fetchSnapshot, 15 * 60 * 1000);
    return () => clearInterval(id);
  }, [fetchSnapshot]);

  // Refresh tickers every 60 sec
  useEffect(() => {
    fetchTickers();
    const id = setInterval(fetchTickers, 60 * 1000);
    return () => clearInterval(id);
  }, [fetchTickers]);

  // Live price every 10 sec (only after initial snapshot is loaded)
  useEffect(() => {
    if (!loaded) return;
    fetchLivePrice();
    const id = setInterval(fetchLivePrice, LIVE_PRICE_INTERVAL);
    return () => clearInterval(id);
  }, [loaded, fetchLivePrice]);

  if (!loaded || !userLoaded || strategies === null) return <LoadingScreen error={error} onRetry={fetchSnapshot} />;

  // A hash pointing at a section this user can't see (bookmark, signed out, role
  // changed) falls back to Overview without rewriting the URL. Match traders by
  // baseId so Strategy (#/trader/<slug>) and Operator (.../ops) highlight the same
  // sidebar row even when admin sidebar id is the /ops default.
  const trader = parseTraderHash(section);
  const firstTrader = visible.find(s => s.strategy);
  const wanted = section === 'ai'
    ? (firstTrader && (firstTrader.baseId || firstTrader.id))
    : trader ? trader.baseId
    : parseReportsHash(section) ? 'reports'
    : section;
  const meta = visible.find(s => (s.baseId || s.id) === wanted || s.id === wanted) || visible[0];
  // current = Strategy base for Section routing / screenLabel math; sidebar uses
  // meta.id (admin default /ops) so the same row stays active on both surfaces.
  const current = meta.baseId || meta.id;
  // Strategy surface is always viewMode=user (including admin-as-investor).
  // Operator chrome only on #/trader/<slug>/ops for admins.
  const viewMode = (isAdmin && trader && trader.ops && meta.strategy) ? 'admin' : 'user';
  const screenLabel = (trader && trader.ops && meta.strategy) ? (current + '/ops') : current;

  return (
    <div className={'app' + (navOpen ? ' nav-open' : '')} data-screen-label={screenLabel}>
      <Sidebar active={meta.id} onChange={navigate} user={user} sections={visible} onCloseNav={() => setNavOpen(false)} />
      <div className="nav-backdrop" onClick={() => setNavOpen(false)} />
      <main style={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
        <TopBar sectionMeta={meta} strip={strip} onRefresh={fetchSnapshot} refreshing={refreshing} viewMode={viewMode} isTrader={!!meta.strategy} onOpenNav={() => setNavOpen(true)} user={user} />
        <div className="page-pad" style={{ padding: '22px 22px 40px', flex: 1 }}>
          <SectionIntro section={current} meta={meta} user={user} viewMode={viewMode} onNavigate={navigate} />
          <Section id={current} route={section} user={user} meta={meta} viewMode={viewMode} sections={visible} onNavigate={navigate} />
        </div>
      </main>
      <HelpDialog />
      <W_Assistant user={user} onNavigate={navigate} />
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
