// Panels: LeftRail, TibebChat, NodeDetail, QuickAdd, TradeCard, ContextMenu
const { useEffect, useRef, useState, useMemo, useCallback } = React;

// ─────── small atoms ───────
function Spark({ data, color = '#7aa7ff', w = 80, h = 22 }) {
  if (!data?.length) return null;
  const min = Math.min(...data), max = Math.max(...data);
  const range = max - min || 1;
  const pts = data.map((v, i) => {
    const x = (i / (data.length - 1)) * w;
    const y = h - ((v - min) / range) * h;
    return `${x.toFixed(1)},${y.toFixed(1)}`;
  }).join(' ');
  const lastY = h - ((data[data.length-1] - min) / range) * h;
  return (
    <svg width={w} height={h} className="spark">
      <defs>
        <linearGradient id={`sparkfill-${color.replace('#','')}`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor={color} stopOpacity="0.25"/>
          <stop offset="100%" stopColor={color} stopOpacity="0"/>
        </linearGradient>
      </defs>
      <polygon points={`0,${h} ${pts} ${w},${h}`} fill={`url(#sparkfill-${color.replace('#','')})`} />
      <polyline points={pts} fill="none" stroke={color} strokeWidth="1.4"/>
      <circle cx={w} cy={lastY} r="2" fill={color}/>
    </svg>
  );
}

function VerifyBadge({ verdict, sources, allNodes, setFocused }) {
  const [open, setOpen] = useState(false);
  const map = { green: '✓', yellow: '·', red: '−' };
  const cls = `vbadge v-${verdict}`;
  return (
    <span className={cls} onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}>
      <span className="vbadge-icon">{map[verdict]}</span>
      {open && (
        <span className="vbadge-pop">
          <span className="vbadge-pop-head">
            {verdict === 'green' && 'Verified · 2+ sources'}
            {verdict === 'yellow' && 'Single source'}
            {verdict === 'red' && 'Unverified'}
          </span>
          {sources.map(sid => {
            const n = allNodes.find(x => x.id === sid);
            if (!n) return null;
            return (
              <span key={sid} className="vbadge-src" onClick={(e) => { e.stopPropagation(); setFocused(sid); setOpen(false); }}>
                <span className="vbadge-pub">{n.publisher || n.label}</span>
                <span className="vbadge-date">{n.date || ''}</span>
                <span className="vbadge-ex">{n.excerpt || n.summary || ''}</span>
              </span>
            );
          })}
          <span className="vbadge-why">Why this verdict? Cross-checked against {sources.length} source{sources.length>1?'s':''}.</span>
        </span>
      )}
    </span>
  );
}

function SourcePill({ id, allNodes, setFocused }) {
  const n = allNodes.find(x => x.id === id);
  if (!n) return null;
  const label = n.publisher || n.label;
  return (
    <span
      className="src-pill"
      onClick={() => setFocused(id)}
      title={n.label}
    >
      {label}
    </span>
  );
}

// ─────── Left Rail (workspace switcher) ───────
function LeftRail({ collapsed, setCollapsed, webs, activeWebId, setActiveWebId, onCreateWeb, onRenameWeb, onDeleteWeb }) {
  const [renamingId, setRenamingId] = useState(null);

  if (collapsed) {
    return (
      <aside className="rail rail-collapsed">
        <button className="rail-mini-btn rail-mini-btn--expand" onClick={() => setCollapsed(false)} title="Expand sidebar">
          <svg width="12" height="12" viewBox="0 0 14 14"><path d="M5 3l4 4-4 4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round"/></svg>
        </button>
      </aside>
    );
  }

  return (
    <aside className="rail">
      <div className="rail-section">
        <div className="rail-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
          <span>Webs</span>
          <button className="rail-mini-btn" onClick={() => setCollapsed(true)} title="Minimize sidebar">
            <svg width="12" height="12" viewBox="0 0 14 14"><path d="M9 3l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round"/></svg>
          </button>
        </div>
        <div className="webs-list">
          {webs.map(w => {
            const isActive = w.id === activeWebId;
            const nodeCount = w.nodes.length;
            return (
              <div key={w.id} className={`web-item ${isActive ? 'active' : ''}`} onClick={() => setActiveWebId(w.id)}>
                <div className="web-item-main">
                  {renamingId === w.id ? (
                    <input
                      autoFocus
                      className="web-rename-input"
                      defaultValue={w.name}
                      onClick={e => e.stopPropagation()}
                      onBlur={e => { onRenameWeb(w.id, e.target.value || w.name); setRenamingId(null); }}
                      onKeyDown={e => {
                        if (e.key === 'Enter') { onRenameWeb(w.id, e.target.value || w.name); setRenamingId(null); }
                        if (e.key === 'Escape') setRenamingId(null);
                      }}
                    />
                  ) : (
                    <div className="web-name" onDoubleClick={(e) => { e.stopPropagation(); setRenamingId(w.id); }}>{w.name}</div>
                  )}
                  <div className="web-meta">
                    <span className="web-count">{nodeCount} {nodeCount === 1 ? 'node' : 'nodes'}</span>
                    <span className="web-dot">·</span>
                    <span className="web-edit">{w.lastEdit}</span>
                  </div>
                </div>
                {isActive && (
                  <div className="web-item-actions" onClick={e => e.stopPropagation()}>
                    <button className="web-act-btn" title="Rename" onClick={() => setRenamingId(w.id)}>
                      <svg width="11" height="11" viewBox="0 0 12 12"><path d="M2 10h2l5-5-2-2-5 5v2zM7 3l2 2" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>
                    </button>
                    <button className="web-act-btn" title="Delete" onClick={() => onDeleteWeb(w.id)}>
                      <svg width="11" height="11" viewBox="0 0 12 12"><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/></svg>
                    </button>
                  </div>
                )}
              </div>
            );
          })}
        </div>
        <button className="new-web-btn" onClick={onCreateWeb}>
          <svg width="11" height="11" viewBox="0 0 12 12"><path d="M6 2v8M2 6h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
          New web
        </button>
      </div>

      <div className="rail-foot">
        <div className="rail-foot-row"><span>Status</span><span className="rail-status"><span className="pulse"/>Live · paper</span></div>
        <div className="rail-foot-row"><span>Last sync</span><span>2s ago</span></div>
      </div>
    </aside>
  );
}

// ─────── Tibeb Chat ───────
const CHAT_STORAGE_KEY = 'jarvis.chat.v3';
function loadChat(webId) {
  try {
    const all = JSON.parse(localStorage.getItem(CHAT_STORAGE_KEY) || '{}');
    return all[webId] || null;
  } catch (_) { return null; }
}

// Phase 3.B — fire-and-forget server-side persistence on send. Local
// cache stays authoritative for the UI; the server sync just makes the
// thread portable across devices. Only runs for real persisted users.
function persistChatRemote(webId, message) {
  try {
    const u = window.TIBEB_USER;
    if (!u || !u.auth_configured || u.is_local) return;
    if (!message || !message.role || (!message.text && !message.actions)) return;
    fetch(`/api/webs/${encodeURIComponent(webId)}/chat`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        role: message.role,
        text: message.text || '',
        sources: message.sources || null,
        actions: message.actions || null,
        actionState: message.actionState || null,
      }),
    }).catch(() => {});
  } catch (_) {}
}
function saveChat(webId, messages) {
  try {
    const all = JSON.parse(localStorage.getItem(CHAT_STORAGE_KEY) || '{}');
    // Only persist serializable fields
    all[webId] = messages.map(m => ({
      role: m.role,
      text: typeof m.text === 'string' ? m.text : null,
      sources: m.sources || null,
      actions: m.actions || null,
      actionState: m.actionState || null,
    })).filter(m => m.text !== null || m.actions);
    localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(all));
  } catch(_) {}
}

// Public surface for non-TibebChat components (e.g. inventHypothesis in
// app.jsx, the trade-card "send to chat" flow, Mad Max result handlers) to
// drop a message into the persistent chat thread without owning React state.
// Dispatches a CustomEvent which TibebChat listens for and merges into its
// messages state. Falls back to localStorage if nobody is mounted yet.
window.tibebAppendChat = function appendChat(webId, message) {
  if (!webId || !message || !message.text) return;
  // Persist to storage so it survives a remount.
  try {
    const all = JSON.parse(localStorage.getItem(CHAT_STORAGE_KEY) || '{}');
    const prior = Array.isArray(all[webId]) ? all[webId] : [];
    all[webId] = [...prior, {
      role: message.role || 'jarvis',
      text: message.text,
      sources: message.sources || null,
      actions: message.actions || null,
      actionState: message.actionState || null,
    }];
    localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(all));
  } catch (_) {}
  // And dispatch so a mounted TibebChat picks it up live.
  try {
    window.dispatchEvent(new CustomEvent('tibeb:chat:append', {
      detail: { webId, message },
    }));
  } catch (_) {}
  // Phase 3.B — server-persist for logged-in users.
  persistChatRemote(webId, message);
};

function TibebChat({ allNodes, edges, activeWeb, tweaks, selected, focused, setFocused, onConnectTrade, tradeProposal, onAcceptTrade, onDismissTrade, tradeAccepted, tradeDismissed, onRefineTrade, onApplyActions, collapsed, setCollapsed }) {
  const webId = activeWeb.id;
  const [messages, setMessages] = useState(() => {
    const saved = loadChat(webId);
    if (saved && saved.length) return saved;
    return [{
      role: 'jarvis',
      text: `Hi — I'm Tibeb. I can see all ${allNodes.length} nodes in "${activeWeb.name}" and I'll keep my memory across refreshes.\n\nAsk me anything about the web, or tell me to add/connect/rename nodes — I'll propose the edit and you accept it with one click. Try: "add a thesis that rates stay higher for longer" or "connect [t1] to NVDA as supports".`,
    }];
  });
  const [input, setInput] = useState('');
  const [listening, setListening] = useState(false);
  const [typing, setTyping] = useState(false);
  const inputRef = useRef(null);
  const scrollRef = useRef(null);

  useEffect(() => {
    window.__focusTibeb = () => inputRef.current?.focus();
    return () => { window.__focusTibeb = null; };
  }, []);

  // Reload messages when web changes. Phase 3.B — when a real user is
  // logged in, hydrate from the server first; localStorage is the local
  // cache that gives instant paint while the API fetch lands. The two
  // layers are eventually-consistent: server wins after the fetch, the
  // user sees their cross-device chat history.
  useEffect(() => {
    const saved = loadChat(webId);
    if (saved && saved.length) setMessages(saved);
    else setMessages([{
      role: 'jarvis',
      text: `Switched to "${activeWeb.name}". Fresh memory for this web — ask me anything.`,
    }]);
    // Server hydrate, fire-and-forget, only for persisted users
    const u = window.TIBEB_USER;
    const isPersistedUser = !!(u && u.auth_configured && !u.is_local);
    if (!isPersistedUser) return;
    let cancelled = false;
    (async () => {
      try {
        const r = await fetch(`/api/webs/${encodeURIComponent(webId)}/chat?limit=200`);
        if (!r.ok) return;
        const j = await r.json();
        const remote = (j.messages || []).map(m => ({
          role: m.role,
          text: m.text,
          sources: m.sources || undefined,
          actions: m.actions || undefined,
          actionState: m.actionState || undefined,
        }));
        if (cancelled) return;
        if (remote.length > 0) setMessages(remote);
      } catch (_) {
        // Non-fatal — local cache is fine
      }
    })();
    return () => { cancelled = true; };
  }, [webId]);

  // Persist locally on every change
  useEffect(() => { saveChat(webId, messages); }, [messages, webId]);

  // Listen for external pushes from window.tibebAppendChat. inventHypothesis
  // (and other top-level flows) emit a 'tibeb:chat:append' event when they
  // want to land a status message in the chat thread without owning React
  // state. We only accept events targeted at the currently active web.
  useEffect(() => {
    const onAppend = (e) => {
      if (!e || !e.detail) return;
      if (e.detail.webId !== webId) return;
      const msg = e.detail.message;
      if (!msg || !msg.text) return;
      setMessages(m => [...m, {
        role: msg.role || 'jarvis',
        text: msg.text,
        sources: msg.sources || undefined,
        actions: msg.actions || undefined,
        actionState: msg.actionState || undefined,
      }]);
    };
    window.addEventListener('tibeb:chat:append', onAppend);
    return () => window.removeEventListener('tibeb:chat:append', onAppend);
  }, [webId]);

  useEffect(() => {
    scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
  }, [messages, typing]);

  const send = (text) => {
    const userText = text || input.trim();
    if (!userText) return;
    const userMsg = { role: 'user', text: userText };
    setMessages(m => [...m, userMsg]);
    persistChatRemote(webId, userMsg);  // Phase 3.B — fire-and-forget
    setInput('');
    setTyping(true);
    // Phase 6 R · #F — Streaming render. Append a placeholder assistant
    // message immediately and update its `text` as chunks arrive. Once
    // the stream completes, parse out any ```actions JSON block and
    // finalize the message with sources + actions.
    const streamMsgId = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
    setMessages(m => [...m, {
      role: 'jarvis', text: '', sources: [], actions: null,
      actionState: null, _streaming: true, _streamId: streamMsgId,
    }]);
    const onChunk = (_piece, full) => {
      setMessages(m => m.map(msg =>
        msg._streamId === streamMsgId ? { ...msg, text: full } : msg
      ));
    };
    askTibeb(userText, allNodes, edges, activeWeb, tweaks, [...messages, userMsg],
             { selected, focused, onChunk })
      .then(reply => {
        setTyping(false);
        const { text: cleanText, actions } = parseActions(reply);
        const finalMsg = {
          role: 'jarvis',
          text: cleanText,
          sources: extractCitedIds(cleanText, allNodes),
          actions: actions.length ? actions : null,
          actionState: null,
        };
        setMessages(m => m.map(msg =>
          msg._streamId === streamMsgId ? finalMsg : msg
        ));
        persistChatRemote(webId, finalMsg);
      })
      .catch((err) => {
        setTyping(false);
        const friendly = (window.friendlyChatError && window.friendlyChatError(err)) ||
          "Something went wrong on the model side. Try again or rephrase.";
        const errMsg = { role: 'jarvis', text: friendly };
        setMessages(m => m.map(msg =>
          msg._streamId === streamMsgId ? errMsg : msg
        ));
        persistChatRemote(webId, errMsg);
      });
  };

  const handleActionDecision = (msgIdx, decision) => {
    setMessages(m => m.map((msg, i) => {
      if (i !== msgIdx) return msg;
      if (decision === 'accept') {
        const result = onApplyActions(msg.actions);
        return { ...msg, actionState: 'accepted', actionResult: result };
      }
      return { ...msg, actionState: 'rejected' };
    }));
  };

  const onKey = (e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      send();
    }
  };

  // ─────── Voice loop ───────
  // Web Speech API (recognition) + browser SpeechSynthesis (TTS) +
  // continuous-mode toggle. Supports barge-in: starting the mic while TTS is
  // speaking cancels the TTS so the user can interrupt at any time.
  const recognitionRef = useRef(null);
  const [voiceMode, setVoiceMode] = useState(false);   // continuous flag
  const [speaking, setSpeaking] = useState(false);     // TTS active
  const [liveTranscript, setLiveTranscript] = useState('');
  const lastSpokenIdxRef = useRef(-1);                 // dedup TTS per message
  const sendRef = useRef(send);
  sendRef.current = send;

  // Init SpeechRecognition once
  useEffect(() => {
    const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
    if (!SR) return;  // Firefox / unsupported — chat still works via typing
    const r = new SR();
    r.continuous = false;          // we drive continuous mode ourselves
    r.interimResults = true;
    r.lang = 'en-US';
    r.onstart = () => setListening(true);
    r.onend = () => setListening(false);
    r.onerror = (e) => {
      setListening(false);
      if (e.error && e.error !== 'no-speech' && e.error !== 'aborted') {
        console.warn('[voice] recognition error:', e.error);
      }
    };
    r.onresult = (e) => {
      let interim = '';
      let final = '';
      for (let i = e.resultIndex; i < e.results.length; i++) {
        const t = e.results[i][0].transcript;
        if (e.results[i].isFinal) final += t;
        else interim += t;
      }
      if (final) {
        setLiveTranscript('');
        setInput('');
        sendRef.current(final.trim());
      } else {
        setLiveTranscript(interim);
      }
    };
    recognitionRef.current = r;
    return () => { try { r.stop(); } catch(_) {} };
  }, []);

  // ElevenLabs audio playback element — created lazily on first speak.
  const audioElRef = useRef(null);

  // Speak the latest Tibeb reply while voice mode is on. Routes through
  // /api/voice/speak (ElevenLabs Flash v2.5 with SETTINGS.elevenlabs_voice_id
  // override) so the user hears the same custom voice configured for
  // their Convai agent — NOT the browser's robotic SpeechSynthesis.
  // Falls back to browser TTS only when the backend is unreachable.
  // Auto-restarts the mic after speech ends so the conversation flows.
  useEffect(() => {
    if (!voiceMode) return;
    const lastIdx = messages.length - 1;
    const last = messages[lastIdx];
    if (!last || last.role !== 'jarvis') return;
    if (lastSpokenIdxRef.current >= lastIdx) return;
    lastSpokenIdxRef.current = lastIdx;

    // Strip markdown/citations for cleaner spoken output
    const spokenText = (last.text || '')
      .replace(/\*\*([^*]+)\*\*/g, '$1')      // bold
      .replace(/\*([^*]+)\*/g, '$1')          // italic
      .replace(/`([^`]+)`/g, '$1')            // inline code
      .replace(/\[s_?[a-z0-9]+\]/gi, '')      // citation pills
      .replace(/\[t\d+\]|\[k_[A-Z]+\]|\[c\d+\]/g, '')  // node refs
      .trim();
    if (!spokenText) return;

    setSpeaking(true);

    // Shared "speech ended" hook — restarts the mic for the next turn.
    const onDone = () => {
      setSpeaking(false);
      if (voiceMode && recognitionRef.current) {
        try { recognitionRef.current.start(); } catch(_) {}
      }
    };

    (async () => {
      // Two error surfaces:
      //   1. fetch / blob fails → backend outage → browser TTS fallback
      //   2. audio.play() rejects (autoplay policy) → DO NOT fall back
      //      to robotic SpeechSynthesis; just surface the issue so the
      //      user knows to click first.
      let audioReady = false;
      try {
        const resp = await fetch('/api/voice/speak', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ text: spokenText.slice(0, 1500) }),
        });
        if (!resp.ok) throw new Error(`speak ${resp.status}`);
        const blob = await resp.blob();
        const url = URL.createObjectURL(blob);
        if (!audioElRef.current) {
          audioElRef.current = new Audio();
          audioElRef.current.preload = 'auto';
        }
        const el = audioElRef.current;
        el.src = url;
        el.onended = () => { onDone(); URL.revokeObjectURL(url); };
        el.onerror = () => { onDone(); };
        audioReady = true;
        try {
          await el.play();
        } catch (playErr) {
          // Autoplay blocked — audio exists, browser won't play yet.
          // eslint-disable-next-line no-console
          console.warn('[panels] audio.play() blocked by autoplay policy. Click any input to enable audio.');
          onDone();
        }
      } catch (err) {
        // ElevenLabs unreachable. NO browser-TTS fallback — silence
        // is the honest signal. Robotic voice is explicitly unwanted.
        // eslint-disable-next-line no-console
        console.warn('[panels] elevenlabs speak failed (silent — no robotic fallback):', err);
        onDone();
      }
    })();
  }, [messages, voiceMode]);

  const toggleVoice = () => {
    const r = recognitionRef.current;
    if (!r) {
      // No browser support — show a one-shot tip
      setMessages(m => [...m, { role: 'jarvis', text: "Your browser doesn't support Web Speech recognition. Try Chrome or Safari." }]);
      return;
    }
    // Barge-in: cancel any in-flight TTS so the user can interrupt.
    // We now use an <audio> element backed by /api/voice/speak (ElevenLabs)
    // so we pause that first; the speechSynthesis cancel is the
    // fallback-path tear-down.
    if (speaking) {
      try { audioElRef.current?.pause(); } catch(_) {}
      try { window.speechSynthesis?.cancel(); } catch(_) {}
      setSpeaking(false);
    }
    if (listening) {
      // Stop both the mic and continuous mode
      try { r.stop(); } catch(_) {}
      setVoiceMode(false);
      setLiveTranscript('');
    } else {
      // Prime the audio element inside this click handler so subsequent
      // autoplay attempts have a user-gesture chain. Without this,
      // Chrome/Safari block audio.play() on the first Tibeb reply and
      // the user hears either silence or (worse, pre-fix) robotic TTS.
      try {
        if (!audioElRef.current) {
          audioElRef.current = new Audio();
          audioElRef.current.preload = 'auto';
        }
        // 1-frame silent WAV to grant autoplay permission.
        audioElRef.current.src = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=';
        audioElRef.current.play().catch(() => {});
      } catch (_) {}
      // Start continuous mode
      setVoiceMode(true);
      try { r.start(); } catch(_) {}
    }
  };

  if (collapsed) {
    return (
      <aside className="chat chat-collapsed">
        <button className="chat-collapse-btn" onClick={() => setCollapsed(false)} title="Expand Tibeb">
          <svg width="14" height="14" viewBox="0 0 14 14"><path d="M9 3l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round"/></svg>
        </button>
        <div className="chat-collapsed-spine">
          <div className="chat-collapsed-avatar">
            <svg viewBox="0 0 24 24" width="14" height="14"><circle cx="12" cy="12" r="3" fill="currentColor"/><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.5"/></svg>
          </div>
          <div className="chat-collapsed-label">TIBEB</div>
          <div className="chat-collapsed-count">{messages.length}</div>
        </div>
      </aside>
    );
  }

  return (
    <aside className="chat">
      <div className="chat-head">
        <div className="chat-title">
          <div className="chat-avatar">
            <svg viewBox="0 0 24 24" width="14" height="14"><circle cx="12" cy="12" r="3" fill="#0a0e1a"/><circle cx="12" cy="12" r="9" fill="none" stroke="#0a0e1a" strokeWidth="1.5"/></svg>
          </div>
          <div>
            <div className="chat-name-row">
              <span className="chat-name">Tibeb</span>
              <button className="rail-mini-btn chat-mini-btn" title="Minimize chat" onClick={() => setCollapsed(true)}>
                <svg width="12" height="12" viewBox="0 0 14 14" fill="none"><path d="M5 3l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
              </button>
            </div>
            <div className="chat-sub">
              {speaking ? <span className="chat-state-pill state-speaking">● speaking</span>
                : listening ? <span className="chat-state-pill state-listening">● listening</span>
                : voiceMode ? <span className="chat-state-pill state-voice">voice mode</span>
                : typing ? <span className="chat-state-pill state-thinking">thinking…</span>
                : <>connected · {allNodes.length} nodes</>
              }
            </div>
          </div>
        </div>
        <div className="chat-actions">
          <button className="ico-btn" title="History">
            <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.2"/><path d="M7 4v3l2 1.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/></svg>
          </button>
          <button className="ico-btn" title="Settings">
            <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="2" stroke="currentColor" strokeWidth="1.2"/><path d="M7 1v2M7 11v2M1 7h2M11 7h2M2.5 2.5l1.5 1.5M10 10l1.5 1.5M2.5 11.5L4 10M10 4l1.5-1.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/></svg>
          </button>
        </div>
      </div>

      <div className="chat-scroll" ref={scrollRef}>
        {messages.map((m, i) => (
          <div key={i} className={`bubble bubble-${m.role}`}>
            <div className="bubble-meta">
              {m.role === 'jarvis' ? 'Tibeb' : 'You'}
            </div>
            <div className="bubble-body">
              {typeof m.text === 'function'
                ? m.text()
                : (typeof m.text === 'string' ? renderWithCitations(m.text, allNodes, setFocused) : m.text)}
            </div>
            {m.role === 'jarvis' && m.sources && m.sources.length > 0 && (
              <div className="bubble-cites">
                <span className="cites-label">Cited:</span>
                {m.sources.map(sid => <SourcePill key={sid} id={sid} allNodes={allNodes} setFocused={setFocused}/>)}
              </div>
            )}
            {m.role === 'jarvis' && m.actions && (
              <ActionProposal
                actions={m.actions}
                state={m.actionState}
                onAccept={() => handleActionDecision(i, 'accept')}
                onReject={() => handleActionDecision(i, 'reject')}
                allNodes={allNodes}
              />
            )}
          </div>
        ))}

        {/* Trade card lives in the chat scroll */}
        {!tradeDismissed && (
          <TradeCard
            proposal={tradeProposal}
            allNodes={allNodes}
            setFocused={setFocused}
            accepted={tradeAccepted}
            onAccept={onAcceptTrade}
            onDismiss={onDismissTrade}
            onRefine={onRefineTrade}
          />
        )}

        {typing && (
          <div className="bubble bubble-jarvis">
            <div className="bubble-meta">Tibeb · typing</div>
            <div className="bubble-body"><span className="typing"><span/><span/><span/></span></div>
          </div>
        )}
      </div>

      <div className="chat-foot">
        {selected.length >= 2 && (
          <div className="selection-hint">
            <span>{selected.length} nodes selected</span>
            <button className="btn-trade" onClick={onConnectTrade}>
              Connect into trade <kbd>⌘⇧T</kbd>
            </button>
          </div>
        )}
        {!typing && messages.length < 3 && (
          <div className="suggest-chips">
            <button className="suggest-chip" onClick={() => send('What are the strongest links in this web?')}>strongest links</button>
            <button className="suggest-chip" onClick={() => send('Where could this thesis be wrong?')}>where could this be wrong</button>
            <button className="suggest-chip" onClick={() => send('Give me one trade idea for this web.')}>one trade idea</button>
          </div>
        )}
        {liveTranscript && (
          <div className="chat-live-transcript">
            <span className="live-dot"/>
            <span className="live-text">{liveTranscript}</span>
          </div>
        )}
        <div className="chat-input-wrap">
          <button
            className={`voice-btn ${listening ? 'listening' : ''} ${voiceMode ? 'voice-mode' : ''} ${speaking ? 'speaking' : ''}`}
            onClick={toggleVoice}
            title={
              speaking ? 'Tap to interrupt Tibeb'
              : listening ? 'Tap to stop listening'
              : voiceMode ? 'Tap to exit voice mode'
              : 'Tap to start continuous voice chat'
            }
          >
            {speaking
              ? <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M2 5h2l3-2v8l-3-2H2zM10 5a3 3 0 010 4M11.5 3.5a5 5 0 010 7" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round"/></svg>
              : listening
              ? <span className="voice-pulse"/>
              : <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="5" y="2" width="4" height="7" rx="2" stroke="currentColor" strokeWidth="1.2"/><path d="M3 7a4 4 0 008 0M7 11v2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/></svg>
            }
          </button>
          <input
            ref={inputRef}
            className="chat-input"
            value={input}
            onChange={e => setInput(e.target.value)}
            onKeyDown={onKey}
            placeholder={
              listening ? 'Listening…'
              : voiceMode ? 'Voice mode on — tap mic to stop'
              : `Ask Tibeb about "${activeWeb.name}" or hold mic for voice…`
            }
          />
          <button className="send-btn" onClick={() => send()} disabled={!input.trim() || typing}>
            <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M2 7h10M8 3l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
          </button>
        </div>
        <div className="chat-tips">
          <kbd>/</kbd> focus · <kbd>⌘K</kbd> quick-add · <kbd>mic</kbd> continuous voice
        </div>
      </div>
    </aside>
  );
}

// ─────── Tibeb brain: real LLM calls + grounded context ───────
async function askTibeb(question, allNodes, edges, activeWeb, tweaks, history, ctx) {
  const personaPrompts = {
    analyst: "Disciplined analyst. Terse. Cite sources. State the thesis chain in one breath.",
    contrarian: "Contrarian. Find the other side fast. Name the weakness in one line.",
    cautious: "Risk-first. Lead with the failure mode. Brief.",
    aggressive: "Conviction-driven. Highest-edge angle. No hedging language.",
  };
  const horizonPrompts = {
    intraday: "intraday/very short-term tactical",
    swing: "swing-trade horizon (days to weeks)",
    quarter: "1–3 month positional (catalyst-driven)",
    year: "12-month fundamental view",
  };
  const jargonPrompts = {
    plain: "Use plain English. Avoid Greeks, R:R, and trader jargon. Explain trade-offs in human terms.",
    light: "Mix of trader language and plain English.",
    full: "Full trader vernacular. Greeks, basis points, vol surfaces, deltas — assume sophistication.",
  };

  // Compact graph context — every node, abbreviated
  const ctxNodes = allNodes.map(n => {
    if (n.type === 'thesis') return `[${n.id}] THESIS: ${n.label}${n.summary ? ' — ' + n.summary : ''}`;
    if (n.type === 'ticker') return `[${n.id}] TICKER ${n.label} ${n.name || ''}: $${n.price} (${n.change > 0 ? '+' : ''}${n.change}%) sentiment ${n.sentiment}`;
    if (n.type === 'catalyst') return `[${n.id}] CATALYST: ${n.label}${n.date ? ' · ' + n.date : ''}${n.impact ? ' · ' + n.impact : ''}${n.summary ? ' — ' + n.summary : ''}`;
    if (n.type === 'source') return `[${n.id}] SOURCE ${n.publisher || ''}${n.date ? ' (' + n.date + ')' : ''}: "${n.excerpt || n.label}"`;
    return `[${n.id}] ${n.type}: ${n.label}`;
  }).join('\n');
  const ctxEdges = edges.map(e => `${e.from} --${e.kind}--> ${e.to}`).join('\n');

  // Highlight what user is currently looking at
  const focusedNode = ctx?.focused ? allNodes.find(n => n.id === ctx.focused) : null;
  const selectedNodes = (ctx?.selected || []).map(id => allNodes.find(n => n.id === id)).filter(Boolean);

  let viewing = '';
  if (focusedNode) {
    viewing += `User is currently viewing the detail panel for [${focusedNode.id}] (${focusedNode.type}: "${focusedNode.label}"). When they say "this", "it", or refer to no specific node, they mean this node.`;
  }
  if (selectedNodes.length) {
    viewing += `\nUser has multi-selected: ${selectedNodes.map(n => `[${n.id}] ${n.label}`).join(', ')}.`;
  }
  if (!viewing) viewing = 'No node is currently focused.';

  const recent = history.slice(-10).map(m =>
    `${m.role === 'user' ? 'USER' : 'TIBEB'}: ${typeof m.text === 'string' ? m.text : '[non-text message]'}`
  ).join('\n');

  const prompt = `You are Tibeb, a markets co-pilot embedded in a thought-graph workspace. The user owns a web of nodes (theses, tickers, catalysts, sources) connected by edges. You answer questions grounded in this web AND propose edits to it.

PERSONA: ${personaPrompts[tweaks.persona] || personaPrompts.analyst}
RISK TOLERANCE: ${tweaks.riskTolerance}/100 (${tweaks.riskTolerance < 30 ? 'low' : tweaks.riskTolerance > 70 ? 'high' : 'moderate'})
HORIZON: ${horizonPrompts[tweaks.horizon] || horizonPrompts.swing}
LANGUAGE: ${jargonPrompts[tweaks.jargon] || jargonPrompts.plain}

CURRENT WEB: "${activeWeb.name}" — ${activeWeb.tagline}

NODES (full list):
${ctxNodes || '(empty)'}

EDGES:
${ctxEdges || '(none)'}

CONTEXT:
${viewing}

RECENT CONVERSATION (chronological):
${recent}

USER'S NEW QUESTION: ${question}

────────── HOW TO REPLY ──────────

1) ANSWER tight. Default to **1–2 sentences**. Use a short paragraph only if the question genuinely needs it (max ~50 words). Cite nodes inline with bracketed ids like [t1] or [s3] — the UI renders them as pills. No preamble, no "great question", no recap of what the user said. If they say "this" or "it" without naming a node, they mean the focused node above. Lead with the answer; explain only if asked.

2) IF the user asks you to MODIFY the web (add, connect, rename, remove a node), OR if your answer would clearly be improved by a small edit (e.g. user described a new thesis, or a new source) — propose the edits as a JSON block at the END of your reply, fenced exactly like this:

\`\`\`actions
[
  {"op":"add_node","type":"thesis","label":"Rates stay higher for longer","summary":"Sticky services inflation keeps Fed in pause through 2026.","tempId":"new1"},
  {"op":"connect","from":"new1","to":"t1","kind":"contradicts"},
  {"op":"add_node","type":"ticker","label":"TLT","name":"20Y Treasury ETF","tempId":"new2"},
  {"op":"connect","from":"new1","to":"new2","kind":"mentions"}
]
\`\`\`

Op vocabulary:
- {"op":"add_node","type":"thesis|ticker|catalyst|source","label":"...", optional fields: "summary","name","publisher","date","excerpt","tempId":"newN"}  — tempId is what you reference in subsequent connect ops in the same block
- {"op":"connect","from":"<id or tempId>","to":"<id or tempId>","kind":"supports|contradicts|mentions|triggers"}
- {"op":"rename","id":"<existing id>","label":"new label"}
- {"op":"remove","id":"<existing id>"}
- {"op":"research","query":"..."} — when user asks you to look something up, propose this; the UI will offer to add a placeholder source node

Only emit an actions block when the user is asking for a graph change OR you have a concrete, useful edit to suggest. Don't suggest edits gratuitously. If you have nothing to add, omit the block entirely.

Do NOT use any other code fences. Do NOT explain the JSON. The UI parses it directly.`;

  // Phase 6 R · #F — Stream when caller provides onChunk (chat surface);
  // fall back to atomic completion when not (Mad Max iterations etc.).
  let text = '';
  if (typeof ctx?.onChunk === 'function' && typeof window.streamTibeb === 'function') {
    text = await window.streamTibeb({
      messages: [{ role: 'user', content: prompt }],
      onChunk: ctx.onChunk,
    });
  } else {
    text = await window.claude.complete(prompt);
  }
  return (text || '').trim();
}

// Parse fenced ```actions JSON blocks out of a reply, return clean text + actions[]
function parseActions(reply) {
  if (typeof reply !== 'string') return { text: reply, actions: [] };
  const re = /```actions\s*\n([\s\S]*?)```/i;
  const m = reply.match(re);
  if (!m) return { text: reply, actions: [] };
  let actions = [];
  try {
    const parsed = JSON.parse(m[1].trim());
    if (Array.isArray(parsed)) actions = parsed;
  } catch (_) { /* ignore */ }
  const text = reply.replace(re, '').trim();
  return { text, actions };
}

// ─────── Action proposal card (render in chat) ───────
function ActionProposal({ actions, state, onAccept, onReject, allNodes }) {
  const labelFor = (id) => {
    const n = allNodes.find(x => x.id === id);
    return n ? n.label : id;
  };
  const summarize = (a) => {
    if (a.op === 'add_node') return `Add ${a.type} · ${a.label}`;
    if (a.op === 'connect') return `Connect ${labelFor(a.from)} → ${labelFor(a.to)} (${a.kind})`;
    if (a.op === 'rename') return `Rename ${labelFor(a.id)} → "${a.label}"`;
    if (a.op === 'remove') return `Remove ${labelFor(a.id)}`;
    if (a.op === 'research') return `Research: "${a.query}"`;
    return JSON.stringify(a);
  };
  const iconFor = (op) => {
    if (op === 'add_node') return '+';
    if (op === 'connect') return '→';
    if (op === 'rename') return '✎';
    if (op === 'remove') return '×';
    if (op === 'research') return '⌕';
    return '·';
  };
  return (
    <div className={`action-proposal ${state || ''}`}>
      <div className="action-head">
        <span className="action-eyebrow">Proposed edit{actions.length > 1 ? 's' : ''}</span>
        {state === 'accepted' && <span className="action-status accepted">Applied</span>}
        {state === 'rejected' && <span className="action-status rejected">Dismissed</span>}
      </div>
      <ul className="action-list">
        {actions.map((a, i) => (
          <li key={i} className={`action-row action-${a.op}`}>
            <span className="action-icon">{iconFor(a.op)}</span>
            <span className="action-text">{summarize(a)}</span>
          </li>
        ))}
      </ul>
      {!state && (
        <div className="action-buttons">
          <button className="action-btn accept" onClick={onAccept}>Accept all</button>
          <button className="action-btn reject" onClick={onReject}>Dismiss</button>
        </div>
      )}
    </div>
  );
}

function extractCitedIds(text, allNodes) {
  if (typeof text !== 'string') return [];
  const ids = new Set();
  const valid = new Set(allNodes.map(n => n.id));
  const re = /\[([a-z0-9_]+)\]/gi;
  let m;
  while ((m = re.exec(text)) !== null) {
    if (valid.has(m[1])) ids.add(m[1]);
  }
  return [...ids].slice(0, 8);
}

function renderWithCitations(text, allNodes, setFocused) {
  const valid = new Set(allNodes.map(n => n.id));
  const parts = [];
  const re = /\[([a-z0-9_]+)\]/gi;
  let last = 0, m, key = 0;
  while ((m = re.exec(text)) !== null) {
    if (m.index > last) parts.push(text.slice(last, m.index));
    if (valid.has(m[1])) {
      parts.push(<SourcePill key={key++} id={m[1]} allNodes={allNodes} setFocused={setFocused}/>);
    } else {
      parts.push(m[0]);
    }
    last = m.index + m[0].length;
  }
  if (last < text.length) parts.push(text.slice(last));
  // Convert paragraph breaks
  return parts.flatMap((p, i) => {
    if (typeof p !== 'string') return [p];
    return p.split(/\n\n+/).flatMap((para, j, arr) =>
      j < arr.length - 1 ? [para, <br key={`br-${i}-${j}`}/>, <br key={`br2-${i}-${j}`}/>] : [para]
    );
  });
}

// ─────── Tibeb Tweaks panel ───────
function TibebTweaks({ tweaks, setTweak }) {
  return (
    <TweaksPanel>
      <TweakSection label="Tibeb personality"/>
      <TweakRadio
        label="Persona"
        value={tweaks.persona}
        options={['analyst','contrarian','cautious','aggressive']}
        onChange={v => setTweak('persona', v)}
      />
      <TweakRadio
        label="Horizon"
        value={tweaks.horizon}
        options={['intraday','swing','quarter','year']}
        onChange={v => setTweak('horizon', v)}
      />
      <TweakSlider
        label="Risk tolerance"
        value={tweaks.riskTolerance}
        min={0} max={100} unit=""
        onChange={v => setTweak('riskTolerance', v)}
      />
      <TweakRadio
        label="Language"
        value={tweaks.jargon}
        options={['plain','light','full']}
        onChange={v => setTweak('jargon', v)}
      />
      <TweakToggle
        label="Real AI replies"
        value={tweaks.useAI}
        onChange={v => setTweak('useAI', v)}
      />

      <TweakSection label="Workspace"/>
      <TweakColor
        label="Accent"
        value={tweaks.accent}
        onChange={v => setTweak('accent', v)}
      />
      <TweakToggle
        label="Live price drift"
        value={tweaks.liveDrift}
        onChange={v => setTweak('liveDrift', v)}
      />
      <TweakToggle
        label="Sentiment bar"
        value={tweaks.showSentiment}
        onChange={v => setTweak('showSentiment', v)}
      />
    </TweaksPanel>
  );
}

function synthReply(q, allNodes, setFocused) {
  const lower = q.toLowerCase();
  if (lower.includes('capex') || lower.includes('express')) {
    return (
      <>
        Tracing the capex thesis through the graph: <SourcePill id="t1" allNodes={allNodes} setFocused={setFocused}/> <i>compounds through 2026</i>, supported by <SourcePill id="s1" allNodes={allNodes} setFocused={setFocused}/> and <SourcePill id="s3" allNodes={allNodes} setFocused={setFocused}/>.
        <br/><br/>
        Cleanest expression is the <b>NVDA call spread</b> drafted below — <VerifyBadge verdict="green" sources={['s1','s3']} allNodes={allNodes} setFocused={setFocused}/> on the underlying claims.
        <br/><br/>
        <i>Tail risk:</i> permitting / power. The <SourcePill id="t3" allNodes={allNodes} setFocused={setFocused}/> thesis contradicts <i>Capex compounds</i> if datacenter power buildout slows. Hedged in the proposal via VST puts.
      </>
    );
  }
  if (lower.includes('cheaper') || lower.includes('refine')) {
    return (
      <>
        Refined: shifted to <span className="inline-tag">NVDA Sep 1100/1200 call spread</span> at 2.2% book — same delta, half the premium decay. Hedge cut to 0.6% VST puts. Net cost down 38%, max P&L preserved within ~12%.
      </>
    );
  }
  if (lower.includes('vst') || lower.includes('power') || lower.includes('utility')) {
    return (
      <>
        VST is currently <b>$142.85, −1.24%</b> on the PJM auction miss — <VerifyBadge verdict="yellow" sources={['s7']} allNodes={allNodes} setFocused={setFocused}/>. Sentiment <b>+0.31</b> aggregate. Bull case anchored on <SourcePill id="s7" allNodes={allNodes} setFocused={setFocused}/>; bear case on <SourcePill id="s8" allNodes={allNodes} setFocused={setFocused}/>.
      </>
    );
  }
  if (lower.includes('nvda') || lower.includes('nvidia')) {
    return (
      <>
        NVDA at <b>$1142.38, +2.41%</b>, vol 38.2M. Sentiment <b>+0.74</b> across 4 wires. Catalyst stack: <SourcePill id="c1" allNodes={allNodes} setFocused={setFocused}/> in 25 days; consensus EPS $0.94 — <VerifyBadge verdict="green" sources={['s1','s3']} allNodes={allNodes} setFocused={setFocused}/>.
      </>
    );
  }
  return (
    <>
      Pulling the relevant sub-graph… I see <b>{allNodes.filter(n=>n.type==='thesis').length} theses</b> and <b>{allNodes.filter(n=>n.type==='ticker').length} watched tickers</b> that intersect your question. Want me to walk through any specific node?
    </>
  );
}

// ─────── Trade Card ───────
// SOURCE_THRESHOLD was 100 — a hard gate that blocked first-time users until
// they had 100 source nodes (~5–10 Mad Max sweeps). Beginner audit: this is
// the single biggest blocker to getting value on day 1. We replace the hard
// lock with a SOFT progress meter — Tibeb still proposes a thesis at any
// source count, but flags low-confidence ones with a "support %" derived
// from how many supports vs contradicts the thesis has accumulated.
const SOURCE_THRESHOLD = 5;

function downloadThesisMarkdown(proposal, allNodes, edges) {
  if (!proposal) return;
  const today = new Date().toISOString().slice(0, 10);
  const slug = (proposal.headline || 'thesis').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'thesis';
  const sources = allNodes.filter(n => n.type === 'source');
  const theses = allNodes.filter(n => n.type === 'thesis');
  const tickers = allNodes.filter(n => n.type === 'ticker');
  const catalysts = allNodes.filter(n => n.type === 'catalyst');
  const cited = (proposal.sources || []).map(id => allNodes.find(n => n.id === id)).filter(Boolean);

  let md = '';
  md += `# ${proposal.headline || 'Mispricing Thesis'}\n\n`;
  if (proposal.subtitle) md += `_${proposal.subtitle}_\n\n`;
  md += `**Generated** ${today} · **${sources.length} sources** in support · **${edges.length} edges** · Tibeb · Investing Brain\n\n`;
  md += `> _Long-only / asymmetric upside thesis. The market is mispricing the information below — this report sizes that gap._\n\n`;
  md += `---\n\n`;

  md += `## Executive Summary\n\n${proposal.summary || ''}\n\n`;

  if (proposal.upside) {
    md += `## The Asymmetry\n\n${proposal.upside}\n\n`;
  }

  if (proposal.mispricings?.length) {
    md += `## What the Market Is Getting Wrong\n\n`;
    proposal.mispricings.forEach((m, i) => {
      md += `### ${i + 1}. ${m.claim}\n\n`;
      if (m.consensus) md += `**Consensus view:** ${m.consensus}\n\n`;
      if (m.reality) md += `**What our nodes show:** ${m.reality}\n\n`;
      if (m.evidence?.length) {
        md += `**Evidence chain:**\n\n`;
        m.evidence.forEach(eid => {
          const n = allNodes.find(x => x.id === eid);
          if (n) md += `- \`[${n.id}]\` ${n.label}${n.publisher ? ` — _${n.publisher}_` : ''}${n.excerpt ? `\n  > ${n.excerpt}` : ''}\n`;
        });
        md += `\n`;
      }
    });
  }

  if (cited.length) {
    md += `## Citation Trail (${cited.length})\n\n`;
    cited.forEach(n => {
      md += `### \`[${n.id}]\` ${n.label}\n`;
      const meta = [n.publisher, n.date].filter(Boolean).join(' · ');
      if (meta) md += `_${meta}_\n\n`;
      if (n.url) md += `🔗 [${n.url}](${n.url})\n\n`;
      if (n.excerpt) md += `> ${n.excerpt}\n\n`;
    });
  }

  if (theses.length) {
    md += `## Supporting Theses (${theses.length})\n\n`;
    theses.forEach(t => {
      md += `- \`[${t.id}]\` **${t.label}**${t.summary ? ` — ${t.summary}` : ''}\n`;
    });
    md += `\n`;
  }

  if (catalysts.length) {
    md += `## Catalyst Timeline\n\n`;
    catalysts.forEach(c => {
      md += `- **${c.date || 'TBD'}** — ${c.label}${c.impact ? ` _(${c.impact})_` : ''}${c.summary ? ` — ${c.summary}` : ''}\n`;
    });
    md += `\n`;
  }

  if (tickers.length) {
    md += `## Tickers in Play\n\n`;
    md += `| Symbol | Name | Last | Δ |\n|---|---|---|---|\n`;
    tickers.forEach(t => {
      md += `| ${t.label} | ${t.name || ''} | ${t.price != null ? '$' + t.price : '—'} | ${t.change != null ? (t.change > 0 ? '+' : '') + t.change + '%' : '—'} |\n`;
    });
    md += `\n`;
  }

  md += `---\n\n_Tibeb · Investing Brain · This report identifies pricing inefficiencies vs. the source corpus. Not investment advice._\n`;

  const blob = new Blob([md], { type: 'text/markdown' });
  const blobUrl = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = blobUrl;
  a.download = `${slug}-thesis-${today}.md`;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  setTimeout(() => URL.revokeObjectURL(blobUrl), 5000);
}

function TradeCard({ proposal, allNodes, edges, setFocused, accepted, onAccept, onDismiss, onRefine }) {
  const sourceCount = allNodes.filter(n => n.type === 'source').length;
  const locked = sourceCount < SOURCE_THRESHOLD;

  // Soft progress state — beginner-friendly. The earlier hard "Need 92 more
  // sources" lock used to be the first thing every new user saw and it
  // discouraged them before they had any value. Now the chat works at any
  // source count and we just surface a thesis-strength meter so you know how
  // robustly the web supports its current thesis. (Confidence climbs as you
  // run Deep Research / Mad Max / Quick-add real URLs.)
  if (locked) {
    const pct = Math.min(100, (sourceCount / SOURCE_THRESHOLD) * 100);
    return (
      <div className="trade-card trade-card-locked">
        <div className="trade-head">
          <div>
            <div className="trade-eyebrow">Thesis confidence · early</div>
            <div className="trade-title" style={{ fontSize: 17 }}>Add a few more sources to sharpen this</div>
          </div>
        </div>
        <div className="trade-summary" style={{ marginTop: 4 }}>
          You can chat with Tibeb about this thesis anytime. The more sources the web has, the more confident Tibeb gets — at <b>{SOURCE_THRESHOLD}+ sources</b> it'll start proposing high-conviction mispricing trades.
        </div>
        <div className="trade-lock-progress">
          <div className="lock-bar"><div className="lock-fill" style={{ width: `${pct}%` }}/></div>
          <div className="lock-stats">
            <span className="lock-stat-num">{sourceCount}</span>
            <span className="lock-stat-sep">/</span>
            <span className="lock-stat-den">{SOURCE_THRESHOLD}</span>
            <span className="lock-stat-label" style={{ marginLeft: 6 }}>sources</span>
          </div>
        </div>
        <div className="trade-lock-tips">
          <div className="lock-tip-row">→ Tap <b>Deep Research</b> (Mad Max) — pulls live sources for/against</div>
          <div className="lock-tip-row">→ Or paste URLs into <b>Quick add</b></div>
        </div>
      </div>
    );
  }

  if (!proposal) return null;
  const isNew = !!proposal.headline;
  if (!isNew) return null;

  const { headline, summary, sources, mispricings, upside } = proposal;
  // Backwards-compat: if proposal still uses old bet/ifRight shape, map it
  const hasMispricings = mispricings?.length > 0;

  return (
    <div className={`trade-card trade-card-mispricing ${accepted ? 'accepted' : ''}`}>
      <div className="trade-head">
        <div>
          <div className="trade-eyebrow">Mispricing thesis · {sources?.length || 0} citations</div>
          <div className="trade-title">{headline}</div>
        </div>
        <VerifyBadge verdict="green" sources={(sources || []).slice(0,3)} allNodes={allNodes} setFocused={setFocused}/>
      </div>

      <div className="trade-summary">{summary}</div>

      {upside && (
        <div className="trade-upside">
          <div className="upside-label">The asymmetry</div>
          <div className="upside-text">{upside}</div>
        </div>
      )}

      {hasMispricings && (
        <div className="trade-mispricings">
          <div className="mispricings-label">What the market is missing</div>
          {mispricings.map((m, i) => (
            <div key={i} className="mispricing">
              <div className="mp-head">
                <div className="mp-num">{String(i + 1).padStart(2, '0')}</div>
                <div className="mp-claim">{m.claim}</div>
              </div>
              {m.consensus && (
                <div className="mp-row mp-consensus">
                  <span className="mp-row-label">Consensus</span>
                  <span className="mp-row-text">{m.consensus}</span>
                </div>
              )}
              {m.reality && (
                <div className="mp-row mp-reality">
                  <span className="mp-row-label">Our nodes</span>
                  <span className="mp-row-text">{m.reality}</span>
                </div>
              )}
              {m.evidence?.length > 0 && (
                <div className="mp-evidence">
                  {m.evidence.map(eid => <SourcePill key={eid} id={eid} allNodes={allNodes} setFocused={setFocused}/>)}
                </div>
              )}
            </div>
          ))}
        </div>
      )}

      {!hasMispricings && proposal.bet && (
        // Legacy fallback — show the old shape so existing webs still render
        <div className="trade-bet">
          <div className="trade-bet-label">{proposal.bet.label}</div>
          {proposal.bet.lines.map((l, i) => (
            <div key={i} className="trade-bet-line">
              <div className="bet-what">{l.what}</div>
              <div className="bet-detail">{l.detail}</div>
              <div className="bet-size">{l.size}</div>
            </div>
          ))}
        </div>
      )}

      <div className="trade-cites">
        <span className="cites-label">Built from:</span>
        {(sources || []).map(sid => <SourcePill key={sid} id={sid} allNodes={allNodes} setFocused={setFocused}/>)}
      </div>

      <div className="trade-actions">
        {accepted ? (
          <div className="trade-accepted">
            <svg width="14" height="14" viewBox="0 0 14 14"><path d="M2 7l3 3 7-7" stroke="#10b981" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>
            Logged to thesis journal
          </div>
        ) : (
          <>
            <button className="btn-ghost" onClick={onDismiss}>Not now</button>
            <button className="btn-ghost" onClick={() => downloadThesisMarkdown(proposal, allNodes, edges)} title="Download full thesis report as Markdown">
              <svg width="11" height="11" viewBox="0 0 12 12" style={{ marginRight: 4 }}><path d="M6 2v6M3 5l3 3 3-3M2 10h8" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>
              Thesis report
            </button>
            <button className="btn-accept" onClick={onAccept}>Log thesis</button>
          </>
        )}
      </div>
    </div>
  );
}

// ─────── Real-data ticker chart ───────
// Tries multiple CORS-friendly sources; falls back to AI-imagined plausible series with a clear label.
function RealChart({ symbol, w = 300, h = 64 }) {
  const [state, setState] = useState({ status: 'loading', data: null, change: 0, last: null, source: null, error: null });

  useEffect(() => {
    let cancelled = false;
    if (!symbol) { setState({ status: 'error', error: 'no symbol' }); return; }
    setState(s => ({ ...s, status: 'loading', error: null }));
    const sym = symbol.toUpperCase().replace(/[^A-Z0-9.\-]/g, '');

    // Helper: try a fetch source. Returns {data, last, change} or throws.
    const tryYahooSpark = async () => {
      // CORS-permissive Yahoo spark endpoint via allorigins as a proxy fallback
      const direct = `https://query1.finance.yahoo.com/v7/finance/spark?symbols=${sym}&range=3mo&interval=1d`;
      const proxied = `https://api.allorigins.win/raw?url=${encodeURIComponent(direct)}`;
      let res;
      try { res = await fetch(direct); }
      catch (_) { res = await fetch(proxied); }
      if (!res.ok) throw new Error('yahoo http ' + res.status);
      const j = await res.json();
      const result = j?.spark?.result?.[0]?.response?.[0];
      const closes = result?.indicators?.quote?.[0]?.close?.filter(v => v != null);
      if (!closes || closes.length < 5) throw new Error('yahoo: no closes');
      return closes;
    };

    const trySynthetic = async () => {
      // Last resort: ask Claude for plausible recent closes. Marked clearly as synthetic.
      if (!window.claude?.complete) throw new Error('no fallback');
      const prompt = `Give 60 plausible recent daily closing prices for ${sym} as a JSON array of numbers, oldest to newest, ending near a realistic current price. Reply with ONLY the array, no prose, no markdown.`;
      const txt = await window.claude.complete(prompt);
      const m = txt.match(/\[[\s\S]*\]/);
      if (!m) throw new Error('synthetic: no array');
      const arr = JSON.parse(m[0]).filter(v => typeof v === 'number' && isFinite(v));
      if (arr.length < 10) throw new Error('synthetic: too few');
      return arr;
    };

    (async () => {
      let data, source;
      try {
        data = await tryYahooSpark();
        source = 'YAHOO · 3MO';
      } catch (e1) {
        try {
          data = await trySynthetic();
          source = 'CLAUDE-IMAGINED · NOT LIVE';
        } catch (e2) {
          if (!cancelled) setState({ status: 'error', error: (e1.message || 'fetch') + ' / ' + (e2.message || 'synthetic'), data: null });
          return;
        }
      }
      if (cancelled) return;
      const first = data[0];
      const last = data[data.length - 1];
      const change = ((last - first) / first) * 100;
      setState({ status: 'ok', data, change, last, source, error: null });
    })();

    return () => { cancelled = true; };
  }, [symbol]);

  if (state.status === 'loading') {
    return (
      <div style={{ width: w, height: h, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-dim)', fontSize: 10, letterSpacing: '0.1em' }}>
        <span className="loading-dots">FETCHING REAL DATA</span>
      </div>
    );
  }
  if (state.status === 'error') {
    return (
      <div style={{ width: w, height: h, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-dim)', fontSize: 10, letterSpacing: '0.1em', border: '1px dashed var(--line)', flexDirection: 'column', gap: 4 }}>
        <span>NO LIVE DATA</span>
        <span style={{ fontSize: 9, opacity: 0.6 }}>{state.error}</span>
      </div>
    );
  }
  const synthetic = state.source && state.source.startsWith('CLAUDE');
  return (
    <div>
      <Spark data={state.data} color={state.change >= 0 ? '#10b981' : '#ef4444'} w={w} h={h}/>
      <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 9.5, color: synthetic ? '#ffb84d' : 'var(--text-dim)', letterSpacing: '0.08em', marginTop: 4, fontFamily: "'JetBrains Mono', monospace" }}>
        <span>{state.source}</span>
        <span style={{ color: state.change >= 0 ? '#10b981' : '#ef4444' }}>
          {state.change >= 0 ? '+' : ''}{state.change.toFixed(2)}% PERIOD · ${state.last?.toFixed(2)}
        </span>
      </div>
    </div>
  );
}

// ─────── Source link block (auto URL + Markdown brief generator) ───────
// Heuristic: build a plausible URL from publisher + label/date when one isn't on file.
// We never pretend it's verified — UI shows a "best-guess" tag for auto-generated links.
function publisherDomain(pub) {
  if (!pub) return null;
  const p = pub.toLowerCase();
  const map = [
    ['bloomberg', 'bloomberg.com'],
    ['financial times', 'ft.com'], ['ft', 'ft.com'],
    ['wall street journal', 'wsj.com'], ['wsj', 'wsj.com'],
    ['new york times', 'nytimes.com'], ['nyt', 'nytimes.com'],
    ['reuters', 'reuters.com'],
    ['cnbc', 'cnbc.com'],
    ['the economist', 'economist.com'],
    ['fomc', 'federalreserve.gov'], ['federal reserve', 'federalreserve.gov'],
    ['ercot', 'ercot.com'],
    ['sec', 'sec.gov'],
    ['gs research', 'goldmansachs.com'], ['goldman', 'goldmansachs.com'],
    ['morgan stanley', 'morganstanley.com'],
    ['jp morgan', 'jpmorgan.com'], ['jpmorgan', 'jpmorgan.com'],
    ['trepp', 'trepp.com'],
    ['nvidia', 'nvidia.com'],
    ['amazon', 'aboutamazon.com'],
    ['microsoft', 'microsoft.com'],
    ['meta', 'investor.fb.com'],
    ['google', 'abc.xyz'], ['alphabet', 'abc.xyz'],
    ['vistra', 'vistracorp.com'],
    ['constellation', 'constellationenergy.com'],
  ];
  for (const [k, d] of map) if (p.includes(k)) return d;
  // Fallback: turn "Acme Corp IR" → "acmecorp.com"
  return p.replace(/\s*(ir|research|inc\.?|corp\.?|llc)\b/g, '').replace(/[^a-z0-9]+/g, '') + '.com';
}

// Strict allowlist: domains where a URL is structurally deterministic and
// can be trusted even from an LLM (filings, government data, regulator
// publications). Everything else falls through to the Google search
// fallback unless the user has manually verified it via the ⋯ override.
const TRUSTED_URL_DOMAINS = [
  'sec.gov', 'federalreserve.gov', 'ercot.com', 'eia.gov', 'imf.org',
  'bls.gov', 'bea.gov', 'treasury.gov', 'whitehouse.gov',
  'congress.gov', 'cbo.gov', 'gao.gov', 'fdic.gov', 'occ.gov',
  'fda.gov', 'ftc.gov', 'cftc.gov', 'finra.org',
];

function isLikelyHallucinated(url, label) {
  // The Mad Max LLM doesn't have web search, so when it 'cites' a
  // Bloomberg / FT / WSJ / NYT / Reuters article, it reverse-engineers a
  // plausible URL from the title. Most of those are 404s; the rest are
  // paywalled. Strict policy: trust only TRUSTED_URL_DOMAINS hostnames.
  // Everything else → Google search fallback (always works). User can
  // override per-source via ⋯ which sets node.url_verified_by_user.
  if (!url) return false;
  try {
    const u = new URL(url);
    if (TRUSTED_URL_DOMAINS.some(d => u.hostname.endsWith(d))) return false;
    return true;
  } catch {
    return true;  // unparseable
  }
}

function guessUrl(node) {
  // Phase 9 + Mad-Max fix: never display URLs we can't verify. Trust order:
  //   1. user-edited URL (saved via SourceLinkBlock's ⋯ → real, by definition)
  //   2. URL from a TRUSTED_URL_DOMAINS publisher
  //   3. otherwise, Google search query for title + publisher + date
  // Mad Max LLM-hallucinated URLs get caught by isLikelyHallucinated and
  // routed to the Google fallback. Honest, always-clickable, never dead.
  if (node.url && /^https?:\/\//.test(node.url) && !isLikelyHallucinated(node.url, node.label)) {
    return node.url;
  }
  const title = (node.label || '').trim();
  const publisher = (node.publisher || '').trim();
  const date = (node.date || '').trim();
  if (!title) return '';
  const q = encodeURIComponent(
    [title, publisher, date].filter(Boolean).join(' ')
  );
  return `https://www.google.com/search?q=${q}`;
}

function SourceLinkBlock({ node }) {
  const [url, setUrl] = useState(node.url || '');
  const [autoGenerated, setAutoGenerated] = useState(false);
  const [generating, setGenerating] = useState(false);
  const [showEdit, setShowEdit] = useState(false);
  const [editVal, setEditVal] = useState('');

  // Strict trust:
  //   1. user-verified URL via ⋯ (operator confirmed a real source)
  //   2. web-search-verified URL (Anthropic's web_search cited it)
  //   3. TRUSTED_URL_DOMAINS hostnames (gov / regulator / filings)
  // Anything else → no URL displayed at all. The user explicitly rejected
  // Google search fallbacks: a source needs a real cited URL or it shouldn't
  // claim to be a source.
  useEffect(() => {
    const verified = node.url_verified_by_user || node.url_verified_by_search;
    const hasReal = node.url
      && /^https?:\/\//.test(node.url)
      && (verified || !isLikelyHallucinated(node.url, node.label));
    if (hasReal) {
      setUrl(node.url);
      setAutoGenerated(false);
    } else {
      setUrl('');
      setAutoGenerated(true);  // means 'no verified URL' now, not 'search fallback'
    }
    setShowEdit(false);
  }, [node.id, node.url_verified_by_user, node.url_verified_by_search]);

  const saveUrl = () => {
    const v = editVal.trim();
    if (v) {
      // Mark user-verified so isLikelyHallucinated lets it through next render
      // even if the domain isn't in TRUSTED_URL_DOMAINS (e.g., a real
      // Bloomberg article the user actually opened and copy-pasted).
      node.url = v;
      node.url_verified_by_user = true;
      setUrl(v);
      setAutoGenerated(false);
    }
    setShowEdit(false);
  };

  const generatePDF = async () => {
    setGenerating(true);
    const today = new Date().toISOString().slice(0, 10);
    try {
      // Run multiple Claude calls in parallel for a deep, structured brief —
      // matches the depth of the whole-web export (executive summary, key
      // points, context, implications, counterpoints, related questions).
      const ctx = `SOURCE: ${node.label}
PUBLISHER: ${node.publisher || 'unknown'}
DATE: ${node.date || 'unknown'}
URL: ${url || '(none)'}
EXCERPT: ${node.excerpt || node.summary || '(none)'}`;

      const pCall = async (p) => {
        try { return (await window.claude.complete(p)).trim(); }
        catch { return ''; }
      };

      const [tldr, keyPoints, context, implications, counter, related, dataPoints] = await Promise.all([
        pCall(`Write a 2-sentence TL;DR for this source. Plain text, no markdown, no preamble.\n\n${ctx}`),
        pCall(`List 5-7 KEY POINTS from this source. Output one bullet per line starting with "- ". Each ≤25 words. Be specific — name companies, numbers, dates. No preamble.\n\n${ctx}`),
        pCall(`Write CONTEXT — 2 paragraphs explaining the broader market/macro backdrop this source sits within. Plain prose, no markdown, no headers. ≤200 words total.\n\n${ctx}`),
        pCall(`Write IMPLICATIONS — 2 paragraphs on what this means for investors and which sectors/tickers are affected and how. Plain prose, no markdown, no headers. ≤200 words total.\n\n${ctx}`),
        pCall(`List 3-4 COUNTERPOINTS or risks to the source's narrative. Output one bullet per line starting with "- ". Each ≤25 words. Be specific. No preamble.\n\n${ctx}`),
        pCall(`List 4-5 RELATED QUESTIONS an analyst should investigate next. Output one bullet per line starting with "- ". Each ≤20 words. No preamble.\n\n${ctx}`),
        pCall(`Extract 4-6 specific DATA POINTS / numbers / dates from this source's excerpt and context. Output as "- LABEL: value" per line. If excerpt is light, infer plausible reference figures and prefix them with "(est.) ". No preamble.\n\n${ctx}`),
      ]);

      const cleanBullets = (s) => s
        .split(/\n+/)
        .map(l => l.trim().replace(/^[-•*]\s*/, ''))
        .filter(Boolean);

      // ── Build the markdown brief ──
      const slug = (node.label || 'source').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'source';
      let md = '';
      md += `# ${node.label}\n\n`;
      md += `**${node.publisher || 'Unknown publisher'}**${node.date ? ` · ${node.date}` : ''}${autoGenerated ? ' · _link is a Google search fallback — paste the real URL via ⋯_' : ''}\n\n`;
      if (url) md += `🔗 [${url}](${url})\n\n`;
      md += `_Brief generated ${today} by Tibeb · synthesized from source metadata + Claude analysis. Not a verbatim copy of the original._\n\n`;
      md += `---\n\n`;

      if (tldr) md += `## TL;DR\n\n${tldr}\n\n`;

      if (node.excerpt) md += `## Excerpt on file\n\n> ${node.excerpt.replace(/\n/g, '\n> ')}\n\n`;

      const kp = cleanBullets(keyPoints);
      if (kp.length) md += `## Key Points\n\n${kp.map(b => `- ${b}`).join('\n')}\n\n`;

      const dp = cleanBullets(dataPoints);
      if (dp.length) md += `## Data Points\n\n${dp.map(b => `- ${b}`).join('\n')}\n\n`;

      if (context) md += `## Context\n\n${context}\n\n`;

      if (implications) md += `## Implications\n\n${implications}\n\n`;

      const cp = cleanBullets(counter);
      if (cp.length) md += `## Counterpoints & Risks\n\n${cp.map(b => `- ${b}`).join('\n')}\n\n`;

      const rq = cleanBullets(related);
      if (rq.length) md += `## Related Questions\n\n${rq.map(b => `- ${b}`).join('\n')}\n\n`;

      md += `---\n\n`;
      md += `_Tibeb · Investing Brain · \`${node.id}\`_\n`;

      const blob = new Blob([md], { type: 'text/markdown' });
      const blobUrl = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = blobUrl;
      a.download = `${slug}-${today}.md`;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      setTimeout(() => URL.revokeObjectURL(blobUrl), 5000);
    } finally {
      setGenerating(false);
    }
  };

  return (
    <div className="detail-section">
      <div className="detail-label" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <span>Source Link</span>
        {autoGenerated && (
          <span style={{
            fontSize: 9, padding: '2px 6px', borderRadius: 2,
            background: 'rgba(255, 170, 51, 0.12)', color: '#ffaa33',
            letterSpacing: '0.08em', textTransform: 'uppercase'
          }} title="No verified URL on this source. Paste the real link via the ⋯ button — Tibeb will remember it.">Unverified</span>
        )}
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
        {url ? (
          <a href={url} target="_blank" rel="noopener noreferrer" style={{
            color: 'var(--accent)', fontSize: 12, wordBreak: 'break-all',
            textDecoration: 'underline', textUnderlineOffset: 3, fontFamily: "'JetBrains Mono', monospace"
          }}>{url}</a>
        ) : (
          <div style={{
            color: 'var(--text-dim)', fontSize: 11, fontStyle: 'italic',
            padding: '8px 0', borderLeft: '2px solid var(--catalyst)', paddingLeft: 12,
          }}>
            No verified URL. Open the original article in your browser, copy the link,
            then click ⋯ below to paste it. Tibeb will remember it from then on.
          </div>
        )}
        <div style={{ display: 'flex', gap: 6 }}>
          <button onClick={generatePDF} disabled={generating} style={{
            background: generating ? 'transparent' : 'var(--accent)',
            border: '1px solid var(--accent)', color: generating ? 'var(--accent)' : '#0a0a0c',
            padding: '7px 12px', fontSize: 10.5, letterSpacing: '0.1em', fontWeight: 700,
            textTransform: 'uppercase', cursor: generating ? 'wait' : 'pointer', fontFamily: 'inherit'
          }}>
            {generating ? 'Synthesizing…' : '↓ Markdown Brief'}
          </button>
          <button onClick={() => { setEditVal(url); setShowEdit(s => !s); }} title="Override URL" style={{
            background: 'transparent', border: '1px solid var(--line-2)',
            color: 'var(--text-2)', padding: '7px 10px', fontSize: 13,
            cursor: 'pointer', fontFamily: 'inherit', lineHeight: 1
          }}>⋯</button>
        </div>
        {showEdit && (
          <div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 4 }}>
            <input
              type="url"
              value={editVal}
              placeholder="https://…"
              onChange={e => setEditVal(e.target.value)}
              onKeyDown={e => { if (e.key === 'Enter') saveUrl(); if (e.key === 'Escape') setShowEdit(false); }}
              autoFocus
              style={{
                flex: 1, background: 'transparent', border: '1px solid var(--line-2)',
                color: 'var(--text)', padding: '6px 8px', fontSize: 11.5, fontFamily: "'JetBrains Mono', monospace"
              }}
            />
            <button onClick={saveUrl} style={{
              background: 'transparent', border: '1px solid var(--accent)',
              color: 'var(--accent)', padding: '6px 10px', fontSize: 10.5, letterSpacing: '0.08em',
              textTransform: 'uppercase', cursor: 'pointer', fontFamily: 'inherit'
            }}>Save</button>
          </div>
        )}
      </div>
      {autoGenerated && url && (
        <div style={{ fontSize: 10, color: 'var(--text-dim)', marginTop: 8, lineHeight: 1.5 }}>
          URL not yet verified. Click ⋯ to paste the actual source URL.
        </div>
      )}
    </div>
  );
}

// ─────── Editable field — click to edit, blur/Enter to save, Esc to cancel ───────
function EditableField({ value, onSave, multiline, placeholder, className, style, inputClassName, inputStyle, label }) {
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState(value || '');
  useEffect(() => { setDraft(value || ''); }, [value]);
  const commit = () => {
    setEditing(false);
    if ((draft || '') !== (value || '')) onSave(draft);
  };
  const cancel = () => { setDraft(value || ''); setEditing(false); };

  if (editing) {
    const sharedProps = {
      autoFocus: true,
      value: draft,
      onChange: e => setDraft(e.target.value),
      onBlur: commit,
      onKeyDown: e => {
        if (e.key === 'Escape') { e.preventDefault(); cancel(); }
        if (e.key === 'Enter' && !multiline) { e.preventDefault(); commit(); }
        if (e.key === 'Enter' && multiline && (e.metaKey || e.ctrlKey)) { e.preventDefault(); commit(); }
      },
      placeholder,
      className: inputClassName || 'editable-input',
      style: { ...(inputStyle || {}) },
    };
    return multiline
      ? <textarea {...sharedProps} rows={Math.min(8, Math.max(2, (draft || '').split('\n').length + 1))} />
      : <input {...sharedProps} type="text" />;
  }
  const display = (value || '').toString().trim();
  return (
    <div
      className={className}
      style={{ ...style, cursor: 'text', position: 'relative' }}
      onClick={() => setEditing(true)}
      title={label ? `${label} (click to edit)` : 'Click to edit'}
    >
      {display || <span style={{ color: 'var(--text-dim)', fontStyle: 'italic' }}>{placeholder || 'Click to add'}</span>}
    </div>
  );
}

// ─────── Node Detail Drawer ───────
function NodeDetail({ node, allNodes, edges, news, setFocused, onClose, onNewThesis, onDeleteNode, onUpdateNode }) {
  if (!node) return null;
  const outgoing = edges.filter(e => e.from === node.id || e.to === node.id);
  const newsItems = news[node.id] || [];
  const update = (patch) => onUpdateNode && onUpdateNode(node.id, patch);

  return (
    <div className="detail-drawer">
      <div className="detail-head">
        <div className="detail-type" style={{ color: NODE_COLORS[node.type] }}>
          <span className="dot" style={{background: NODE_COLORS[node.type]}}/>
          {node.type}
        </div>
        <div style={{ display: 'flex', gap: 4 }}>
          <button className="close-btn" title="Delete node" onClick={() => {
            if (confirm(`Delete "${node.label}"? This removes the node and all its connections.`)) {
              onDeleteNode && onDeleteNode(node.id);
            }
          }} style={{ color: '#ff6680' }}>
            <svg width="14" height="14" viewBox="0 0 14 14"><path d="M3 4h8M5 4V3a1 1 0 011-1h2a1 1 0 011 1v1M4 4l1 8a1 1 0 001 1h2a1 1 0 001-1l1-8" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>
          </button>
          <button className="close-btn" onClick={onClose}>
            <svg width="14" height="14" viewBox="0 0 14 14"><path d="M3 3l8 8M11 3l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
          </button>
        </div>
      </div>

      <EditableField
        className="detail-title"
        value={node.label}
        onSave={v => update({ label: v.trim() || node.label })}
        placeholder="Untitled"
        label="Label"
      />
      {(node.type === 'ticker' || node.name !== undefined) && (
        <EditableField
          className="detail-sub"
          value={node.name || ''}
          onSave={v => update({ name: v })}
          placeholder={node.type === 'ticker' ? 'Full company name' : 'Subtitle'}
          label="Name"
        />
      )}
      {(node.type === 'source' || node.publisher !== undefined || node.date !== undefined) && (
        <div className="detail-sub" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
          <EditableField
            value={node.publisher || ''}
            onSave={v => update({ publisher: v })}
            placeholder="Publisher"
            label="Publisher"
            style={{ display: 'inline-block' }}
          />
          <span style={{ color: 'var(--text-dim)' }}>·</span>
          <EditableField
            value={node.date || ''}
            onSave={v => update({ date: v })}
            placeholder="YYYY-MM-DD"
            label="Date"
            style={{ display: 'inline-block', fontFamily: "'JetBrains Mono', monospace" }}
          />
        </div>
      )}
      {node.type === 'catalyst' && (
        <div className="detail-sub" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
          <EditableField
            value={node.date || ''}
            onSave={v => update({ date: v })}
            placeholder="YYYY-MM-DD"
            label="Date"
            style={{ display: 'inline-block', fontFamily: "'JetBrains Mono', monospace" }}
          />
          <span style={{ color: 'var(--text-dim)' }}>·</span>
          <EditableField
            value={node.impact || ''}
            onSave={v => update({ impact: v })}
            placeholder="impact (low/med/high)"
            label="Impact"
            style={{ display: 'inline-block' }}
          />
        </div>
      )}
      {node.date && node.type === 'catalyst' && <div className="detail-sub">{node.date}</div>}

      {node.type === 'ticker' && (
        <div className="ticker-block">
          <div className="ticker-row">
            <div className="ticker-price">${node.price.toFixed(2)}</div>
            <div className={`ticker-change ${node.change >= 0 ? 'up' : 'dn'}`}>
              {(node.change >= 0 ? '+' : '') + node.change.toFixed(2)}% <span>today</span>
            </div>
          </div>
          <div className="ticker-spark"><RealChart symbol={node.label} w={300} h={64}/></div>
          <div className="ticker-stats">
            <div><span className="stat-label">Volume</span><span className="stat-val">{node.volume}</span></div>
            <div><span className="stat-label">Sentiment</span>
              <div className="sentiment-bar">
                <div className="sentiment-fill" style={{
                  width: `${Math.abs(node.sentiment)*50}%`,
                  marginLeft: node.sentiment >= 0 ? '50%' : `${50 - Math.abs(node.sentiment)*50}%`,
                  background: node.sentiment >= 0 ? '#10b981' : '#ef4444',
                }}/>
              </div>
              <span className="stat-val">{node.sentiment >= 0 ? '+' : ''}{node.sentiment.toFixed(2)}</span>
            </div>
          </div>
        </div>
      )}

      <div className="detail-section">
        <div className="detail-label">{node.type === 'source' ? 'Excerpt' : 'Summary'}</div>
        <EditableField
          className="detail-body"
          value={node.type === 'source' ? (node.excerpt || node.summary || '') : (node.summary || node.excerpt || '')}
          onSave={v => update(node.type === 'source' ? { excerpt: v } : { summary: v })}
          placeholder={node.type === 'source' ? 'Add a 1-sentence excerpt…' : 'Add a 1-2 sentence summary…'}
          label={node.type === 'source' ? 'Excerpt' : 'Summary'}
          multiline
        />
      </div>

      {node.type === 'source' && (
        <SourceLinkBlock node={node} />
      )}

      {node.claims && (
        <div className="detail-section">
          <div className="detail-label">Claims</div>
          {node.claims.map((c, i) => (
            <div key={i} className="claim-row">
              <VerifyBadge verdict={c.verdict} sources={c.sources} allNodes={allNodes} setFocused={setFocused}/>
              <span>{c.text}</span>
            </div>
          ))}
        </div>
      )}

      {newsItems.length > 0 && (
        <div className="detail-section">
          <div className="detail-label">News · {newsItems.length}</div>
          {newsItems.map((n, i) => (
            <div key={i} className={`news-row tone-${n.tone}`}>
              <div className="news-head">
                <span className="news-pub">{n.pub}</span>
                <span className="news-date">{n.date}</span>
              </div>
              <div className="news-headline">{n.headline}</div>
            </div>
          ))}
        </div>
      )}

      <div className="detail-section">
        <div className="detail-label">Connections · {outgoing.length}</div>
        {outgoing.map((e, i) => {
          const otherId = e.from === node.id ? e.to : e.from;
          const other = allNodes.find(n => n.id === otherId);
          if (!other) return null;
          const dir = e.from === node.id ? '→' : '←';
          return (
            <button key={i} className="edge-row" onClick={() => setFocused(otherId)}>
              <span className={`edge-kind kind-${e.kind}`}>{e.kind}</span>
              <span className="edge-dir">{dir}</span>
              <span className="edge-other-dot" style={{background: NODE_COLORS[other.type]}}/>
              <span className="edge-other">{other.label}</span>
            </button>
          );
        })}
      </div>

    </div>
  );
}

// ─────── Quick Add Modal ───────
// Supports all four node types: thesis / ticker / catalyst / source.
// Type picker is sticky (remembered for the session). Source still accepts
// paste-a-URL behavior; the others have purpose-built fields.
function QuickAdd({ open, onClose, onAdd }) {
  const [type, setType] = useState(() => {
    try { return localStorage.getItem('jarvis.quickAdd.type') || 'thesis'; } catch { return 'thesis'; }
  });
  useEffect(() => { try { localStorage.setItem('jarvis.quickAdd.type', type); } catch {} }, [type]);

  const [label, setLabel] = useState('');
  const [summary, setSummary] = useState('');
  const [name, setName] = useState('');
  const [date, setDate] = useState('');
  const [impact, setImpact] = useState('med');
  const [text, setText] = useState('');             // source URL or freeform
  const [extracting, setExtracting] = useState(false);
  const firstRef = useRef(null);

  useEffect(() => {
    if (open) {
      setLabel(''); setSummary(''); setName(''); setDate(''); setImpact('med'); setText('');
      setExtracting(false);
      setTimeout(() => firstRef.current?.focus(), 60);
    }
  }, [open]);

  if (!open) return null;

  const canSubmit = (() => {
    if (type === 'source') return text.trim().length > 0;
    if (type === 'ticker') return label.trim().length > 0 && /^[A-Za-z.\-$]{1,8}$/.test(label.trim());
    if (type === 'market' || type === 'science') return label.trim().length > 0;
    return label.trim().length > 0;
  })();

  const submit = () => {
    if (!canSubmit) return;
    setExtracting(true);
    setTimeout(() => {
      let payload;
      if (type === 'thesis') {
        payload = { type: 'thesis', label, summary };
      } else if (type === 'ticker') {
        payload = { type: 'ticker', label, name };
      } else if (type === 'catalyst') {
        payload = { type: 'catalyst', label, date, impact, summary };
      } else if (type === 'market') {
        // Market/economic fact — modeled as a source node with category="market"
        payload = {
          type: 'source', category: 'market',
          label,
          publisher: name || 'Market data',
          date: date || '',
          excerpt: summary || '',
          url: text.trim() || '',
        };
      } else if (type === 'science') {
        payload = {
          type: 'source', category: 'science',
          label,
          publisher: name || 'Scientific',
          date: date || '',
          excerpt: summary || '',
          url: text.trim() || '',
        };
      } else {
        // Plain source — paste a URL or freeform text
        payload = { type: 'source', text, label, date };
      }
      onAdd(payload);
      setExtracting(false);
      onClose();
    }, 350);
  };

  const types = [
    { id: 'thesis',     label: 'Thesis',   sub: 'Sub-claim within the web' },
    { id: 'ticker',     label: 'Ticker',   sub: 'AAPL, NVDA, $CRWV…' },
    { id: 'catalyst',   label: 'Catalyst', sub: 'Earnings, FOMC, product, regulation' },
    { id: 'source',     label: 'Source',   sub: 'URL or excerpt to ingest' },
    { id: 'market',     label: 'Market fact',  sub: 'e.g. "Transformer lead times exceed 130 weeks"' },
    { id: 'science',    label: 'Science fact', sub: 'e.g. "Sandisk + SK Hynix working on HBF"' },
  ];

  const titleByType = {
    thesis:   'Add a thesis',
    ticker:   'Add a ticker',
    catalyst: 'Add a catalyst',
    source:   'Ingest a source',
    market:   'Add a market/economic fact',
    science:  'Add a scientific/factual node',
  };

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal" onClick={e => e.stopPropagation()}>
        <div className="modal-head">
          <div>
            <div className="modal-eyebrow">⌘K · Quick add</div>
            <div className="modal-title">{titleByType[type]}</div>
          </div>
          <button className="close-btn" onClick={onClose}>
            <svg width="14" height="14" viewBox="0 0 14 14"><path d="M3 3l8 8M11 3l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
          </button>
        </div>

        {/* Type picker */}
        <div className="qa-type-row">
          {types.map(t => (
            <button
              key={t.id}
              className={`qa-type-btn ${type === t.id ? 'active' : ''}`}
              onClick={() => setType(t.id)}
              type="button"
              title={t.sub}
            >
              <div className="qa-type-label">{t.label}</div>
              <div className="qa-type-sub">{t.sub}</div>
            </button>
          ))}
        </div>

        {/* Type-specific fields */}
        {type === 'thesis' && (
          <>
            <input
              ref={firstRef}
              type="text"
              className="modal-line"
              placeholder='Headline (e.g. "Hyperscaler capex is structural, not cyclical")'
              value={label}
              onChange={e => setLabel(e.target.value)}
              onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit(); }}
            />
            <textarea
              className="modal-input"
              placeholder="Summary — the asymmetry, the kill criterion, the catalyst timeline."
              value={summary}
              onChange={e => setSummary(e.target.value)}
              onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit(); }}
            />
          </>
        )}

        {type === 'ticker' && (
          <>
            <input
              ref={firstRef}
              type="text"
              className="modal-line"
              placeholder="Symbol — NVDA, $CRWV, MSFT…"
              value={label}
              onChange={e => setLabel(e.target.value.toUpperCase())}
              onKeyDown={e => { if (e.key === 'Enter') submit(); }}
            />
            <input
              type="text"
              className="modal-line"
              placeholder="Full company name (optional)"
              value={name}
              onChange={e => setName(e.target.value)}
              onKeyDown={e => { if (e.key === 'Enter') submit(); }}
            />
            <div className="modal-hint" style={{ padding: '0 4px 4px' }}>
              Live price + 5-day spark hydrate from Massive on next refresh.
            </div>
          </>
        )}

        {type === 'catalyst' && (
          <>
            <input
              ref={firstRef}
              type="text"
              className="modal-line"
              placeholder="Catalyst — e.g. NVDA earnings, FOMC, Hormuz Watch update"
              value={label}
              onChange={e => setLabel(e.target.value)}
              onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit(); }}
            />
            <div style={{ display: 'flex', gap: 8 }}>
              <input
                type="text"
                className="modal-line"
                placeholder="YYYY-MM-DD"
                value={date}
                onChange={e => setDate(e.target.value)}
                style={{ flex: 1, fontFamily: "'JetBrains Mono', monospace" }}
              />
              <select
                className="modal-line"
                value={impact}
                onChange={e => setImpact(e.target.value)}
                style={{ flex: 1 }}
              >
                <option value="low">low impact</option>
                <option value="med">med impact</option>
                <option value="high">high impact</option>
              </select>
            </div>
            <textarea
              className="modal-input"
              placeholder="What's at stake, what would invalidate the read…"
              value={summary}
              onChange={e => setSummary(e.target.value)}
              onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit(); }}
            />
          </>
        )}

        {(type === 'market' || type === 'science') && (
          <>
            <input
              ref={firstRef}
              type="text"
              className="modal-line"
              placeholder={
                type === 'market'
                  ? 'The fact — e.g. "Large transformer lead times exceed 130 weeks"'
                  : 'The fact — e.g. "Sandisk + SK Hynix collaborating on HBF"'
              }
              value={label}
              onChange={e => setLabel(e.target.value)}
              onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit(); }}
            />
            <input
              type="text"
              className="modal-line"
              placeholder={type === 'market' ? 'Source / desk (Bloomberg, Fed, ERCOT…)' : 'Source / venue (paper, IR site, journal…)'}
              value={name}
              onChange={e => setName(e.target.value)}
            />
            <div style={{ display: 'flex', gap: 8 }}>
              <input
                type="text"
                className="modal-line"
                placeholder="YYYY-MM-DD (optional)"
                value={date}
                onChange={e => setDate(e.target.value)}
                style={{ flex: 1, fontFamily: "'JetBrains Mono', monospace" }}
              />
              <input
                type="text"
                className="modal-line"
                placeholder="URL (optional)"
                value={text}
                onChange={e => setText(e.target.value)}
                style={{ flex: 2, fontFamily: "'JetBrains Mono', monospace" }}
              />
            </div>
            <textarea
              className="modal-input"
              placeholder={type === 'market'
                ? 'Why this matters for the hypothesis — what it implies about supply, demand, or market structure.'
                : 'Why this matters for the hypothesis — what it implies about the underlying tech or scientific bet.'
              }
              value={summary}
              onChange={e => setSummary(e.target.value)}
              onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit(); }}
            />
          </>
        )}

        {type === 'source' && (
          <textarea
            ref={firstRef}
            className="modal-input"
            placeholder="Paste a URL or text snippet… Tibeb will extract tickers, themes, and link them into the graph."
            value={text}
            onChange={e => setText(e.target.value)}
            onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit(); }}
          />
        )}

        {extracting && (
          <div className="modal-extracting">
            <span className="spinner"/>
            Adding to web…
          </div>
        )}

        <div className="modal-foot">
          <div className="modal-hint"><kbd>⌘ ↵</kbd> add · <kbd>esc</kbd> cancel</div>
          <button className="btn-accept" onClick={submit} disabled={!canSubmit || extracting}>Add {type}</button>
        </div>
      </div>
    </div>
  );
}

// ─────── Context Menu ───────
function ContextMenu({ menu, onClose, onAction }) {
  if (!menu) return null;
  const items = [
    { id: 'link',    label: 'Link to selection' },
    { id: 'branch',  label: 'Delete branch + descendants', danger: true },
    { id: 'archive', label: 'Archive node' },
    { id: 'copy',    label: 'Copy reference' },
  ];
  return (
    <div className="ctx-overlay" onClick={onClose} onContextMenu={e => { e.preventDefault(); onClose(); }}>
      <div className="ctx-menu" style={{ left: menu.screenX, top: menu.screenY }}>
        <div className="ctx-head">{menu.node.label}</div>
        {items.map(it => (
          <button
            key={it.id}
            className={`ctx-item${it.danger ? ' ctx-item-danger' : ''}`}
            onClick={() => { onAction(it.id, menu.node); onClose(); }}
          >
            {it.label}
          </button>
        ))}
      </div>
    </div>
  );
}

// ─────── Toast ───────
function Toast({ toast }) {
  if (!toast) return null;
  return <div className="toast">{toast}</div>;
}

Object.assign(window, {
  Spark, RealChart, SourceLinkBlock, VerifyBadge, SourcePill, LeftRail, TibebChat, TradeCard, NodeDetail, QuickAdd, ContextMenu, Toast, TibebTweaks, EditableField, askTibeb, extractCitedIds,
});
