// Mad Max Research — iterative debate-mode research loop.
//
// Goal: gather 100 NEW SOURCES (evidence pieces) for or against the web's
// ONE central hypothesis, then return a probabilistic verdict (e.g. "68% YES
// based on 67 supporting / 33 contradicting"). Each iteration alternates
// between OPTIMIST and CONTRARIAN, both grounded in AGENT.md + Vault Brief.
//
// Mad Max does NOT create new thesis nodes. There is exactly one thesis per
// web — the hypothesis itself, rendered as the central `t_hypo` node. Each
// iteration appends evidence (sources, market facts, science facts, tickers,
// catalysts) attached to the central node with supports / contradicts edges.
//
// Sources without a URL must be either:
//   - market/economic facts citable from the vault (`category: 'market'`)
//   - scientific/factual claims grounded in a vault note (`category: 'science'`)
// Loose unsourced claims are forbidden.

const SOURCE_GOAL = 100;        // gather this much evidence before verdict
const MIN_NEW_SOURCES = 100;
// Counted toward the "source" goal: source nodes (any category) — that's
// what's evidence. Tickers + catalysts + edges are scaffolding, not evidence.
const isEvidenceNode = (n) => n && n.type === 'source';

// Compact graph snapshot for the prompt
function snapshotGraph(nodes, edges) {
  const ns = nodes.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)>0?'+':''}${n.change||0}%)`;
    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||'').slice(0,140)}"`;
    return `[${n.id}] ${n.type.toUpperCase()}: ${n.label}`;
  }).join('\n');
  const es = edges.map(e => `${e.from} --${e.kind}--> ${e.to}`).join('\n');
  return { ns, es };
}

// Pull the FIRST top-level JSON object out of a model reply.
// Tolerant of truncation, smart quotes, JS-style comments, trailing commas.
function extractJSON(text) {
  if (!text) return null;
  // Remove ```json / ``` fences if present
  let cleaned = text.replace(/```json\s*/gi, '').replace(/```/g, '');
  // Normalize smart quotes that some models emit instead of straight ones
  cleaned = cleaned
    .replace(/[‘’‚‛]/g, "'")   // ' ' ‚ ‛
    .replace(/[“”„‟]/g, '"');  // " " „ ‟
  // Strip JS-style line comments (// ...) — only outside strings, conservatively
  cleaned = cleaned.replace(/^\s*\/\/[^\n]*$/gm, '');
  // Strip /* */ block comments
  cleaned = cleaned.replace(/\/\*[\s\S]*?\*\//g, '');
  const start = cleaned.indexOf('{');
  if (start < 0) return null;
  cleaned = cleaned.slice(start);

  // Strict pass first
  const tryParse = (s) => { try { return JSON.parse(s); } catch(_) { return null; } };
  const direct = tryParse(cleaned);
  if (direct) return direct;

  // Walk and track depth + string state. Find longest valid prefix and try to repair.
  let depth = 0, brackDepth = 0, inStr = false, esc = false;
  let lastCloseAtZero = -1;
  for (let i = 0; i < cleaned.length; i++) {
    const c = cleaned[i];
    if (esc) { esc = false; continue; }
    if (c === '\\') { esc = true; continue; }
    if (c === '"') { inStr = !inStr; continue; }
    if (inStr) continue;
    if (c === '{') depth++;
    else if (c === '}') { depth--; if (depth === 0) lastCloseAtZero = i; }
    else if (c === '[') brackDepth++;
    else if (c === ']') brackDepth--;
  }
  // Case A: we saw a balanced top-level object — parse up to it
  if (lastCloseAtZero >= 0) {
    const sliced = cleaned.slice(0, lastCloseAtZero + 1);
    const p = tryParse(sliced);
    if (p) return p;
  }
  // Case B: truncated. Try to repair by trimming trailing partial token, closing strings, then closing brackets/braces.
  let repair = cleaned;
  // Drop a trailing partial unicode escape etc — back off until last sane char.
  // If we ended inside a string, terminate it.
  if (inStr) repair += '"';
  // Drop trailing whitespace and trailing comma, partial keys/values (cut at last "," "}", "]" or quote boundary)
  // Find last position that's a clean cut point
  for (let attempts = 0; attempts < 12; attempts++) {
    let candidate = repair;
    candidate = candidate.replace(/,\s*$/, ''); // dangling comma
    candidate = candidate.replace(/:\s*$/, ': null'); // dangling key
    // Close brackets then braces based on depth tally for this candidate
    let d = 0, bd = 0, s = false, e = false;
    for (let i = 0; i < candidate.length; i++) {
      const c = candidate[i];
      if (e) { e = false; continue; }
      if (c === '\\') { e = true; continue; }
      if (c === '"') { s = !s; continue; }
      if (s) continue;
      if (c === '{') d++;
      else if (c === '}') d--;
      else if (c === '[') bd++;
      else if (c === ']') bd--;
    }
    if (s) candidate += '"';
    while (bd-- > 0) candidate += ']';
    while (d-- > 0) candidate += '}';
    // Strip trailing commas before close (`,]` or `,}`), which are common
    // model emission artifacts and break strict JSON.parse.
    const noTrailingCommas = candidate.replace(/,(\s*[\]}])/g, '$1');
    const p = tryParse(noTrailingCommas) || tryParse(candidate);
    if (p) return p;
    // back off one token and retry
    repair = repair.replace(/[^,{}\[\]]*$/, '').replace(/[,{[]$/, m => m === ',' ? '' : m);
    if (!repair) break;
    // strip last property if we cut to a comma
    repair = repair.replace(/,\s*"[^"]*"\s*:\s*[^,}{\[\]]*$/, '');
  }
  return null;
}

// One iteration: ask Claude for the next 3 evidence pieces.
async function runIteration(nodes, edges, activeWeb, tweaks, iterNum, maxIters, startSourceCount) {
  const { ns, es } = snapshotGraph(nodes, edges);

  const totalSources = nodes.filter(isEvidenceNode).length;
  const newSources = totalSources - startSourceCount;
  const verdictUnlocked = newSources >= MIN_NEW_SOURCES;

  // Cycle through the full hedge-fund analyst roster (mirroring brain/0-Agents/)
  // instead of a binary optimist/contrarian flip. Each iteration brings a
  // different lens — fundamentals, growth, hedging, mental models, PhD, portfolio
  // modeling, value — alternating with a contrarian round so the thesis is
  // attacked from every angle. The post-Mad-Max Team auto-engagement still
  // runs the FULL roster on the synthesized verdict; this just makes Mad Max
  // itself richer than just yes-vs-no.
  const ANALYSTS = [
    { tag: 'OPTIMIST',           prompt: 'Argue the hypothesis is TRUE. Surface evidence that confirms the "yes" case. Look for the strongest version of the bullish argument.' },
    { tag: 'CONTRARIAN',         prompt: 'Argue the hypothesis is FALSE. Counter-data, conflicting filings, base-rate failures. Find the cracks. The hypothesis only matters if it can survive fair attack.' },
    { tag: 'FUNDAMENTALS',       prompt: 'Quality of business lens. Unit economics, moats, returns on capital, cash flow durability. Does the underlying business actually deliver if the thesis plays out?' },
    { tag: 'GROWTH',             prompt: 'Growth investor lens. TAM expansion, S-curves, network effects, distribution leverage. Where does compounding accelerate or stall?' },
    { tag: 'HEDGING SPECIALIST', prompt: 'Risk lens. What hedges this exposure? Where does the position fail? Tail risks, correlation regimes, factor sensitivities, gamma traps.' },
    { tag: 'MENTAL MODELS',      prompt: 'Charlie Munger lens. Inversion, base rates, second-order effects, second-order incentives, what would surprise the consensus.' },
    { tag: 'PhD RESEARCHER',     prompt: 'Empirical lens. Cite the data — academic, regulatory, scientific. Where does peer-reviewed evidence (or its absence) support or break the thesis?' },
    { tag: 'PORTFOLIO MODELING', prompt: 'Sizing lens. Position-construction view: Kelly fraction implied by edge, correlation with existing book, optimal expression (LEAPS strike + DTE).' },
    { tag: 'VALUE',              prompt: 'Value investor lens. Margin of safety, downside-first thinking, asset value vs market price, the "is this cheap?" question grounded in earnings power.' },
  ];
  const analystForIter = ANALYSTS[(iterNum - 1) % ANALYSTS.length];
  const isOptimist = analystForIter.tag === 'OPTIMIST';
  const stance = `${analystForIter.tag} — ${analystForIter.prompt} Never fabricate: if the vault has a deep dive, quote its evidence; if it doesn't, propose a real source on a real publisher's domain OR a verifiable economic/scientific fact citable from the vault.`;

  const hypothesis = (activeWeb.hypothesis && activeWeb.hypothesis.question) ? activeWeb.hypothesis : null;
  const hypoBlock = hypothesis && hypothesis.question
    ? `HYPOTHESIS (the ONE binary question this entire web exists to answer — already on the canvas as the central thesis node t_hypo):
  Q:           ${hypothesis.question}
  RESOLVES BY: ${hypothesis.resolveBy || '(unset)'}
  IF YES:      ${hypothesis.yesScenario || '(unset)'}
  IF NO:       ${hypothesis.noScenario || '(unset)'}
  STATUS:      ${hypothesis.status || 'open'}`
    : `HYPOTHESIS: (not set yet — the user should hit Invent before running Mad Max. Skip and stop.)`;

  const prompt = `MAD MAX DEBATE — you are an autonomous research agent gathering 100 evidence pieces for or against the web's ONE central hypothesis. Each iteration you alternate between OPTIMIST and CONTRARIAN. The web converges on a probabilistic verdict (e.g. "68% YES based on 67 supporting / 33 contradicting") once ${MIN_NEW_SOURCES} new evidence pieces are built.

YOUR STANCE THIS ITERATION: ${stance}
HORIZON: ${tweaks.horizon} · RISK: ${tweaks.riskTolerance}/100
WEB:    "${activeWeb.name}" — ${activeWeb.tagline}

${hypoBlock}

CURRENT NODES (${nodes.length}):
${ns}

CURRENT EDGES (${edges.length}):
${es || '(none)'}

PROGRESS: iter ${iterNum}/${maxIters} · ${newSources}/${MIN_NEW_SOURCES} evidence pieces built.
${verdictUnlocked ? 'VERDICT UNLOCKED — return a probabilistic verdict NOW. Set found_trade=true and supply the verdict block.' : `KEEP DIGGING — add 3-7 new nodes this iter from your ${analystForIter.tag} lens. AT LEAST 3 must be "source" nodes (the gate is sources). On top of those, add ANY MIX of additional sources (news/market/science/filing/data), tickers, and catalysts that strengthen the case. There are no caps on tickers, catalysts, or non-evidence sources — only the 3-source minimum and the one-thesis maximum. A rich iter might be: 3 news sources + 1 market fact + 1 science fact + 1 ticker + 1 catalyst.`}

ONE THESIS PER WEB — the central hypothesis is the only thesis. DO NOT create new "thesis"-type nodes (any you propose will be rejected). Everything else can have many: multiple tickers, multiple catalysts, multiple sources of each category.

Every source node you add must connect to the central node t_hypo with kind="supports" (argues for YES) or kind="contradicts" (argues for NO). Tickers connect to t_hypo with "mentions" (or to relevant sources). Catalysts use "triggers" toward whatever they would move.

NODE TYPES YOU MAY USE — multiple instances of any of these are fine:
- "source"   — evidence. Use the "category" field:
                 "news"     news article (URL required, real publisher domain)
                 "market"   economic / market-structure fact — e.g. 'Large
                            transformer lead times exceed 130 weeks'. URL optional
                            if the excerpt cites a vault note (e.g.
                            "per 2-Science & Tech Sectors/Energy for AI/...md").
                 "science"  scientific/factual claim — e.g. 'Sandisk + SK Hynix
                            collaborating on HBF'. URL optional with vault citation.
                 "filing"   10-K/10-Q/8-K (URL on sec.gov)
                 "data"     vendor data point (URL on the data vendor's domain)
- "ticker"   — any stock/ETF relevant to the hypothesis. Live price hydrates
               from Massive automatically. Add new ones whenever a ticker
               surfaces in the iteration's evidence.
- "catalyst" — any dated event that could resolve or move the hypothesis
               (earnings, FOMC, product launches, regulatory deadlines,
               contract awards). Multiple per iteration is encouraged when
               the hypothesis touches several names.

QUALITY GATE FOR MARKET/SCIENCE FACTS WITHOUT URLs: the claim must be specific, quantified, and traceable to a vault note. Cite the vault path in the excerpt. Loose hand-wavy claims are forbidden — only crisp, defensible facts the user could verify in seconds.

Reply with ONLY this minified JSON, NO commentary, NO code fences:

{"think":"<one sentence: stance + the specific gap you're filling>","stance":"${isOptimist ? 'optimist' : 'contrarian'}","nodes":[{"id":"<uniq id>","type":"source|ticker|catalyst","label":"<short>","summary":"<≤12 words>","name":"<co. name if ticker>","date":"<if catalyst/source>","publisher":"<if source>","url":"<if source — required for category=news|filing|data; optional for category=market|science if a vault citation appears in excerpt>","excerpt":"<if source — ≤30 words; for market/science categories, INCLUDE the vault citation in this field>","category":"<if source: news|market|science|filing|data>","impact":"<if catalyst: low|med|high>"}],"edges":[{"from":"<id>","to":"t_hypo","kind":"supports|contradicts"},{"from":"<id>","to":"<id>","kind":"mentions|triggers"}],"found_trade":${verdictUnlocked ? 'true' : 'false'}${verdictUnlocked ? ',"trade":{"name":"<verdict label, e.g. \\"68% YES — agentic billing tide rising\\">","thesis":"<≤40 words explaining the probability split>","verdict":"yes|no|undecided","probability":<0-100, integer — your subjective P(YES) given the evidence>,"supportingCount":<int>,"contradictingCount":<int>,"legs":[{"side":"long","instrument":"<ticker LEAPS only — no CSPs/short-prem>","size":"<%>","rationale":"<short>"}],"expectedReturn":"<+X%>","maxLoss":"<-Y%>","horizon":"<resolveBy date>","conviction":<60-95>,"supports":["<source id>"]}' : ''}}

ID conventions: source=s<N>, ticker=k_<sym>, catalyst=c<N>. EVERY source must have an edge to t_hypo (supports OR contradicts). News/filing/data sources MUST have a url on the publisher's real domain (bloomberg.com, ft.com, wsj.com, sec.gov, federalreserve.gov, ercot.com, IR sites, etc.) — never invent a generic /article URL. JSON ONLY.`;

  let raw;
  let webCitations = [];
  try {
    // Use /api/llm/research which enables Anthropic's server-side web_search
    // tool. The model can browse real articles before citing them — no more
    // hallucinated URLs. Citations are returned alongside text and we map
    // them onto the source nodes after JSON extraction.
    const r = await fetch('/api/llm/research', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        messages: [{ role: 'user', content: prompt }],
        max_tokens: 3000,
        max_searches: 4,
      }),
    });
    if (r.ok) {
      const j = await r.json();
      raw = j.text;
      webCitations = j.citations || [];
    } else {
      throw new Error(`research endpoint ${r.status}`);
    }
  } catch (e) {
    // Fallback: plain /api/llm without web search (still works, but URLs
    // will be allowlist-stripped at staging — search fallback in display).
    try {
      raw = await window.claude.complete({
        messages: [{ role: 'user', content: prompt }],
        max_tokens: 3000,
      });
    } catch (e2) {
      return { error: 'LLM call failed: ' + (e2.message || e.message || 'unknown') };
    }
  }
  const parsed = extractJSON(raw);
  if (!parsed) return { error: 'Could not parse model output', preview: (raw || '').slice(0, 80).replace(/\s+/g, ' ') + '…' };
  parsed._stance = analystForIter.tag.toLowerCase().replace(/[^a-z]/g, '_');
  parsed._stanceLabel = analystForIter.tag;
  parsed._citations = webCitations;
  return parsed;
}

// Place new nodes on the canvas — gentle ring-based layout from a focal node
function positionNew(newNode, allNodes, anchorId) {
  const anchor = allNodes.find(n => n.id === anchorId) || allNodes[0];
  if (!anchor) return { x: (Math.random()-0.5)*400, y: (Math.random()-0.5)*400 };
  // Place around an expanding ring based on how many we've added
  const ring = 180 + Math.floor(allNodes.length / 8) * 80;
  const angle = (allNodes.length * 0.618 * Math.PI * 2) % (Math.PI * 2); // golden-angle spiral
  return {
    x: (anchor.x || 0) + Math.cos(angle) * ring,
    y: (anchor.y || 0) + Math.sin(angle) * ring,
  };
}

async function runMadMax({
  initialNodes, initialEdges, activeWeb, tweaks,
  setNodes, setEdges,
  onLog, onProgress, onResult, shouldStop,
}) {
  // Up to 80 iterations · 3 evidence/iter · the gate is sources, not iters.
  // 80 iters * 3 sources/iter ≈ 240 chances to hit 100 — plenty of slack for
  // contrarian iters that propose 0 valid items.
  const MAX_ITERS = 80;
  // Track the current graph in local accumulators to avoid React stale-state races
  let liveNodes = [...initialNodes];
  let liveEdges = [...initialEdges];
  let foundTrade = null;
  const startSourceCount = initialNodes.filter(isEvidenceNode).length;
  const startCount = initialNodes.length;
  const sourceGoal = MIN_NEW_SOURCES;

  onLog({ tag: 'iter', text: `Starting debate sweep on "${activeWeb.name}". Gathering ${sourceGoal} evidence pieces for/against the hypothesis, then returning a probabilistic verdict.` });

  let consecFails = 0;
  for (let i = 1; i <= MAX_ITERS; i++) {
    if (shouldStop()) { onLog({ tag: 'warn', text: 'Halted by user.' }); break; }
    const sourcesNow = liveNodes.filter(isEvidenceNode).length;
    const newSources = sourcesNow - startSourceCount;
    if (newSources >= sourceGoal && foundTrade) break;
    onLog({ tag: 'iter', text: `Iter ${i} · evidence ${newSources}/${sourceGoal} · nodes ${liveNodes.length} · edges ${liveEdges.length}` });
    onProgress({ iter: i, max: MAX_ITERS, nodeCount: liveNodes.length, newNodes: newSources, sourceCount: sourcesNow });

    // Run the iteration with one silent retry on parse failure. Most parse
    // fails are transient — the model's next attempt usually returns clean
    // JSON. We only count iterations as "consecutive failures" if BOTH the
    // first try and the retry fail.
    let result = await runIteration(liveNodes, liveEdges, activeWeb, tweaks, i, MAX_ITERS, startSourceCount);
    if (shouldStop()) break;
    if (result.error) {
      result = await runIteration(liveNodes, liveEdges, activeWeb, tweaks, i, MAX_ITERS, startSourceCount);
      if (shouldStop()) break;
    }
    if (result.error) {
      consecFails++;
      onLog({ tag: 'warn', text: result.error + (result.preview ? ' — got: ' + result.preview : '') });
      if (consecFails >= 5) {
        onLog({ tag: 'warn', text: 'Five failed iterations in a row — aborting sweep. The model is not returning parseable JSON.' });
        break;
      }
      continue;
    }
    consecFails = 0;

    const thinkText = result.think || result.thinking;
    if (thinkText) {
      const label = result._stanceLabel || result._stance || 'iter';
      // Short tag for the log column — first 4 letters of the agent name keeps
      // the column tidy across all 9 personas.
      const tag = String(label).slice(0, 4).toLowerCase();
      onLog({ tag, text: `[${label}] ${thinkText}` });
    }

    // Accept either short or long field names
    const rawNodes = result.nodes || result.new_nodes || [];
    const rawEdges = result.edges || result.new_edges || [];
    // Web search citations from /api/llm/research — real publisher URLs
    // returned by Anthropic's web_search tool. We match these onto source
    // nodes by title overlap so each cited source gets a verified URL.
    const webCitations = result._citations || [];

    // Helper: pick the best citation for a given source node by token overlap
    // between citation.title and node.label. Returns null if no decent match.
    function _matchCitation(node) {
      if (!node || !node.label || webCitations.length === 0) return null;
      const norm = (s) => (s || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter(w => w.length >= 4);
      const labelTokens = new Set(norm(node.label));
      if (labelTokens.size === 0) return null;
      let best = null, bestScore = 0;
      for (const c of webCitations) {
        const cTokens = norm(c.title);
        let overlap = 0;
        for (const t of cTokens) if (labelTokens.has(t)) overlap += 1;
        const score = overlap / Math.max(1, Math.min(labelTokens.size, cTokens.length));
        if (score > bestScore) { best = c; bestScore = score; }
      }
      // Require at least 35% token overlap to accept the match
      return bestScore >= 0.35 ? best : null;
    }

    // Add new nodes — but REJECT any new "thesis" type. There is exactly one
    // thesis per web (the central t_hypo node) and Mad Max must not create
    // siblings. If the model proposes one, downgrade it to a "source" with
    // the appropriate stance category.
    const usedIds = new Set(liveNodes.map(n => n.id));
    const addedNodes = [];
    let rejectedTheses = 0;
    for (const nn of rawNodes) {
      if (!nn.id || !nn.type || !nn.label) continue;

      // Quality gate: drop "thesis" nodes (one hypothesis per web).
      if (nn.type === 'thesis') {
        rejectedTheses += 1;
        continue;
      }

      // URL provenance for source nodes — three-tier trust model:
      //   1. Web-search citation match (verified by Anthropic's web_search
      //      tool — Claude actually browsed and read this article)
      //   2. TRUSTED_URL_DOMAINS (filings, regulators, gov data)
      //   3. Anything else → strip; display layer falls back to Google search
      if (nn.type === 'source') {
        // (1) try to match a real web-search citation
        const cite = _matchCitation(nn);
        if (cite && cite.url) {
          nn.url = cite.url;
          nn.url_verified_by_search = true;  // bypasses the allowlist check
          if (cite.title && (!nn.publisher || nn.publisher === 'unknown')) {
            // Use the cited title's domain as a publisher hint if missing
            try { nn.publisher = nn.publisher || new URL(cite.url).hostname.replace('www.', ''); }
            catch {}
          }
          if (cite.cited_text && !(nn.excerpt || '').trim()) {
            nn.excerpt = cite.cited_text.slice(0, 280);
          }
        } else if (nn.url) {
          // (2) check trusted-domain allowlist
          const TRUSTED = [
            'sec.gov','federalreserve.gov','ercot.com','eia.gov','bls.gov',
            'bea.gov','treasury.gov','whitehouse.gov','imf.org','congress.gov',
            'cbo.gov','gao.gov','fdic.gov','occ.gov','fda.gov','ftc.gov',
            'cftc.gov','finra.org',
          ];
          try {
            const u = new URL(nn.url);
            if (!TRUSTED.some(d => u.hostname.endsWith(d))) {
              delete nn.url;
            }
          } catch {
            delete nn.url;
          }
        }
      }

      // STRICT no-Google-fallback policy: every source on the canvas must
      // have a verified URL (web_search cited / trusted-domain / user-pasted)
      // OR be a market/science fact that cites a vault note path.
      // Anything else gets DROPPED here so the canvas only shows real sources.
      if (nn.type === 'source' && !nn.url) {
        const cat = (nn.category || 'news').toLowerCase();
        const hasVaultCite = /(\b\d-[A-Za-z][^\s/]*\/[^"]+\.md|agent\.md)/i.test(nn.excerpt || '');
        const isVaultCitedFact = ['market', 'science'].includes(cat) && hasVaultCite;
        if (!isVaultCitedFact) {
          continue;  // no verified web URL + no vault cite → reject
        }
      }

      // Disambiguate id collisions
      let id = nn.id;
      if (usedIds.has(id)) id = id + '_' + Math.random().toString(36).slice(2,5);
      usedIds.add(id);

      // Anchor new sources to t_hypo if available, otherwise to the last node
      const hypoId = liveNodes.find(n => n.isHypothesis)?.id || 't_hypo';
      const anchorId = rawEdges.find(e => e.from === nn.id || e.to === nn.id)
        ? ((rawEdges.find(e => e.from === nn.id)?.to) || (rawEdges.find(e => e.to === nn.id)?.from))
        : (liveNodes.find(n => n.id === hypoId) ? hypoId : liveNodes[liveNodes.length-1]?.id);
      const pos = positionNew(nn, liveNodes, anchorId);
      const cleaned = { ...nn, id, x: pos.x, y: pos.y };
      // Default category to 'news' for sources missing it
      if (cleaned.type === 'source' && !cleaned.category) cleaned.category = 'news';
      // Reasonable defaults
      if (cleaned.type === 'ticker' && typeof cleaned.sentiment !== 'number') cleaned.sentiment = 0;
      if (cleaned.type === 'ticker' && !cleaned.spark) {
        const base = cleaned.price || 100;
        cleaned.spark = Array.from({length: 16}, (_,k) => +(base * (1 + (Math.random()-0.5)*0.06 + k*0.002)).toFixed(2));
      }
      addedNodes.push(cleaned);
      liveNodes.push(cleaned);
    }
    if (addedNodes.length) {
      const sourceAdded = addedNodes.filter(isEvidenceNode).length;
      onLog({ tag: 'add', text: `+${sourceAdded} evidence · ${addedNodes.map(n => `[${n.type[0]}${n.category ? ':'+n.category[0] : ''}] ${n.label}`).slice(0,3).join(', ')}${addedNodes.length>3 ? `, +${addedNodes.length-3} more`:''}` });
    }
    if (rejectedTheses) {
      onLog({ tag: 'warn', text: `dropped ${rejectedTheses} new thesis node${rejectedTheses === 1 ? '' : 's'} — one hypothesis per web.` });
    }

    // Add new edges (only those where both endpoints exist)
    const validIds = new Set(liveNodes.map(n => n.id));
    const addedEdges = rawEdges.filter(e => e.from && e.to && validIds.has(e.from) && validIds.has(e.to) && e.from !== e.to);
    if (addedEdges.length) {
      liveEdges = [...liveEdges, ...addedEdges];
      onLog({ tag: 'link', text: `+${addedEdges.length} edges` });
    }

    // Push to React state so the canvas updates live
    setNodes(liveNodes);
    setEdges(liveEdges);
    const sourcesNowAfter = liveNodes.filter(isEvidenceNode).length;
    const newSourcesAfter = sourcesNowAfter - startSourceCount;
    onProgress({ iter: i, max: MAX_ITERS, nodeCount: liveNodes.length, newNodes: newSourcesAfter, sourceCount: sourcesNowAfter });

    // Verdict is ONLY allowed once we've gathered ≥ goal evidence pieces.
    if (newSourcesAfter >= MIN_NEW_SOURCES && result.found_trade && result.trade) {
      foundTrade = result.trade;
      // Compute supporting/contradicting evidence from edges to t_hypo
      const hypoId = liveNodes.find(n => n.isHypothesis)?.id || 't_hypo';
      const supports = liveEdges.filter(e => (e.to === hypoId || e.from === hypoId) && e.kind === 'supports').length;
      const contradicts = liveEdges.filter(e => (e.to === hypoId || e.from === hypoId) && e.kind === 'contradicts').length;
      const total = supports + contradicts;
      const probFromEdges = total > 0 ? Math.round((supports / total) * 100) : null;
      const probability = (typeof foundTrade.probability === 'number')
        ? Math.max(0, Math.min(100, foundTrade.probability))
        : (probFromEdges != null ? probFromEdges : 50);
      foundTrade.probability = probability;
      foundTrade.supportingCount = foundTrade.supportingCount ?? supports;
      foundTrade.contradictingCount = foundTrade.contradictingCount ?? contradicts;
      onLog({
        tag: 'find',
        text: `VERDICT: ${probability}% YES · ${foundTrade.supportingCount} supporting / ${foundTrade.contradictingCount} contradicting · "${foundTrade.name}"`,
      });
      break;
    } else if (result.found_trade && newSourcesAfter < MIN_NEW_SOURCES) {
      onLog({ tag: 'warn', text: `Verdict ignored — only ${newSourcesAfter}/${MIN_NEW_SOURCES} evidence pieces. Keep digging.` });
    }

    // Brief pause for visual rhythm
    await new Promise(r => setTimeout(r, 250));
  }

  const finalSources = liveNodes.filter(isEvidenceNode).length - startSourceCount;
  if (!foundTrade && finalSources >= MIN_NEW_SOURCES) {
    onLog({ tag: 'done', text: `Built ${finalSources} evidence pieces — could not converge on a probabilistic verdict. Review the graph for the strongest cluster.` });
  } else if (!foundTrade) {
    onLog({ tag: 'done', text: `Sweep ended early. ${finalSources}/${MIN_NEW_SOURCES} evidence pieces built — need full set for a verdict.` });
  } else {
    onLog({ tag: 'done', text: `Sweep complete · ${foundTrade.probability}% ${foundTrade.verdict || 'YES'} · ${finalSources} evidence pieces.` });
  }

  onResult({
    trade: foundTrade,
    nodeCount: liveNodes.length,
    edgeCount: liveEdges.length,
    sourceCount: liveNodes.filter(isEvidenceNode).length,
  });
}

// ─────── React overlay component ───────
const { useState: mmUseState, useEffect: mmUseEffect, useRef: mmUseRef } = React;

// Roster used for the analyst-rotation chip strip — must match the ANALYSTS
// array in buildPrompt() above. Order matters: cycle index = (iter - 1) % len.
const MM_ROSTER_LABELS = [
  'Optimist', 'Contrarian', 'Fundamentals', 'Growth', 'Hedging',
  'Mental Models', 'PhD', 'Portfolio Modeling', 'Value',
];

// ─────────────────────────────────────────────────────────────────────
// BeliefCard — renders a canonical Mad Max Belief returned by
// /api/madmax/run. Same fields as the visionOS/iOS/Android BeliefCard.
// Pure presentational; no hooks, no fetch — caller passes the Belief in.
// ─────────────────────────────────────────────────────────────────────
function BeliefCard({ belief, title }) {
  if (!belief) return null;
  const pct = x => `${Math.round(x * 100)}%`;
  const percentish = x => x == null ? '—' : `${(x * 100).toFixed(1)}%`;
  const compositeColor = belief.P_composite >= 0.60
    ? 'var(--accent, #00d4aa)'
    : belief.P_composite <= 0.40
      ? 'var(--danger, #ff4466)'
      : 'var(--catalyst, #d4b800)';
  const mono = { fontFamily: "'JetBrains Mono', monospace" };
  return (
    <div style={{
      marginTop: 10,
      padding: 12,
      background: 'rgba(0, 212, 170, 0.05)',
      border: '0.5px solid rgba(0, 212, 170, 0.30)',
      borderRadius: 10,
    }}>
      {title !== null && (
        <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
          <span style={{ color: 'var(--accent, #00d4aa)', ...mono, fontSize: 11,
                         fontWeight: 800, letterSpacing: '0.15em' }}>
            ƒ  {title || 'CANONICAL MATH SIDECAR'}
          </span>
          {belief.divergence_flag && (
            <span style={{ marginLeft: 'auto', ...mono, fontSize: 9, fontWeight: 800,
                           padding: '2px 6px', borderRadius: 999, letterSpacing: '0.05em',
                           background: 'rgba(217, 168, 95, 0.18)', color: '#d9a85f' }}>
              △ DIV {Math.round(belief.divergence * 100)}%
            </span>
          )}
        </div>
      )}

      {/* Tier 1 — composite / graph / pool */}
      <div style={{ display: 'flex', gap: 16, marginBottom: 6 }}>
        <Stat label="COMPOSITE" value={pct(belief.P_composite)} color={compositeColor} />
        <Stat label="GRAPH" value={belief.P_graph != null ? pct(belief.P_graph) : '—'} />
        <Stat label="POOL" value={pct(belief.P_team)} />
      </div>

      {/* Tier 2 — CI + prior */}
      <div style={{ display: 'flex', gap: 12, marginBottom: 6 }}>
        <Mini label="80% CI" value={`[${pct(belief.P_LB)} - ${pct(belief.P_UB)}]`} />
        <Mini label="PRIOR"
              value={`${pct(belief.prior_used)} · n=${belief.reference_class_n}${belief.reference_class_credible ? '' : ' (low)'}`}
              color={belief.reference_class_credible ? undefined : '#d9a85f'} />
      </div>

      {/* Tier 3 — Kelly + position size */}
      <div style={{ display: 'flex', gap: 12, marginBottom: 6 }}>
        <Mini label="KELLY (P_LB)"
              value={`full ${percentish(belief.kelly_full)} · used ${percentish(belief.kelly_used)}`}
              color={(belief.kelly_used || 0) > 0 ? 'var(--accent, #00d4aa)' : 'var(--danger, #ff4466)'} />
        {belief.position_size_pct != null && (
          <Mini label="POSITION SIZE"
                value={`${belief.position_size_pct.toFixed(2)}% NAV`}
                color={belief.position_size_pct > 0 ? 'var(--accent, #00d4aa)' : 'var(--danger, #ff4466)'}
                bold />
        )}
      </div>

      {/* Tier 4 — audit */}
      <div style={{ ...mono, fontSize: 9, color: 'var(--text-dim, #888)', lineHeight: 1.5 }}>
        {belief.contributing_agents} agents · {belief.pool_method}
        {belief.graph_solvable && ` · graph: ${belief.graph_diagnostic}`}
        {belief.is_calibrated && (
          <span style={{ display: 'block' }}>
            isotonic calibrated · n={belief.calibration_sample_size}
          </span>
        )}
      </div>
    </div>
  );
}

function Stat({ label, value, color }) {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
      <span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: 9,
                     fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-dim, #888)' }}>
        {label}
      </span>
      <span style={{ fontSize: 16, fontWeight: 800, color: color || 'var(--text, #fff)' }}>
        {value}
      </span>
    </div>
  );
}

function Mini({ label, value, color, bold }) {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
      <span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: 9,
                     fontWeight: 700, color: 'var(--text-dim, #888)' }}>
        {label}
      </span>
      <span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: 11,
                     fontWeight: bold ? 800 : 600, color: color || 'var(--text, #fff)' }}>
        {value}
      </span>
    </div>
  );
}


function MadMaxOverlay({ open, onClose, onHalt, running: runningProp, log, progress, result, onAcceptTrade, onDismiss, activeWeb }) {
  if (!open) return null;
  const scrollRef = mmUseRef(null);
  mmUseEffect(() => {
    if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
  }, [log.length]);

  const newSources = progress.newNodes || 0;  // legacy field name; really the new evidence count
  const pct = Math.min(100, (newSources / MIN_NEW_SOURCES) * 100);
  const finalPct = pct;
  // Phase 6 S — running state comes from parent (per-web map) not
  // from absence-of-result. Run can be complete (result set) but the
  // overlay can be hidden + reopened later.
  const running = runningProp != null ? runningProp : !result;
  const probability = result?.trade?.probability;
  const verdict = result?.trade?.verdict;
  const iter = progress.iter || 0;
  const currentAnalystIdx = iter > 0 ? (iter - 1) % MM_ROSTER_LABELS.length : -1;
  const currentAnalyst = currentAnalystIdx >= 0 ? MM_ROSTER_LABELS[currentAnalystIdx] : null;

  // Phase 2A.3 — canonical Belief state. Fetched on demand by clicking
  // "Run canonical math" in the result section. The Belief adds the
  // Brier-weighted log opinion pool + absorbing Markov chain + γ-blended
  // composite + 80% CI + Kelly-with-P_LB sizing to the local debate-loop
  // output.
  const [belief, setBelief] = mmUseState(null);
  const [beliefFetching, setBeliefFetching] = mmUseState(false);
  const [beliefError, setBeliefError] = mmUseState(null);

  async function runCanonicalMath() {
    if (!result?.trade) return;
    setBeliefFetching(true);
    setBeliefError(null);
    try {
      const b = await upgradeMadMaxToBelief(activeWeb, result.trade);
      setBelief(b);
    } catch (e) {
      setBeliefError((e?.message || String(e)).slice(0, 200));
    } finally {
      setBeliefFetching(false);
    }
  }

  return (
    <div className="mm-overlay">
      <div className="mm-panel">
        <div className="mm-head">
          <div>
            <div className="mm-eyebrow">
              <span className="pulse-dot"/>
              {running ? 'Mad Max · digging' : 'Mad Max · complete'}
            </div>
            <div className="mm-title">
              <em>Deep research</em> in motion
            </div>
          </div>
          {/* Phase 6 S — three close paths:
              - Hide (background): keeps run alive, click topbar pill to reopen
              - Stop: hard-aborts the run
              - Close: tidy after a complete run */}
          <div style={{ display: 'flex', gap: 6 }}>
            {running && (
              <button className="mm-stop" onClick={onClose}
                      title="Hide overlay; the run keeps going. Reopen via the orange MAD MAX pill in the topbar."
                      style={{ background: 'transparent', border: '1px solid var(--line-2)' }}>
                Hide
              </button>
            )}
            <button className="mm-stop" onClick={running ? (onHalt || onClose) : onClose}>
              {running ? 'Stop' : 'Close'}
            </button>
          </div>
        </div>

        <div className="mm-stats">
          <div className="mm-stat" title="How many search rounds Mad Max has run on this thesis. More rounds = wider source net.">
            <div className="mm-stat-label">Round</div>
            <div className="mm-stat-val">{progress.iter || 0}<span style={{color:'var(--text-dim)', fontSize:11}}>/{progress.max || 80}</span></div>
          </div>
          <div className="mm-stat" title={`How many distinct sources Mad Max has pulled. Goal: ${MIN_NEW_SOURCES}.`}>
            <div className="mm-stat-label">Sources</div>
            <div className="mm-stat-val hot">{newSources}<span style={{color:'var(--text-dim)', fontSize:11}}>/{MIN_NEW_SOURCES}</span></div>
          </div>
          <div className="mm-stat" title="Tibeb's confidence the thesis resolves YES, after weighing every source for and against.">
            <div className="mm-stat-label">Confidence</div>
            <div className="mm-stat-val" style={{
              color: typeof probability === 'number'
                ? (probability >= 60 ? '#00d4aa' : probability <= 40 ? '#ff4466' : '#d4b800')
                : 'var(--text-dim)'
            }}>
              {typeof probability === 'number'
                ? `${probability}% ${verdict ? verdict.toUpperCase() : 'YES'}`
                : 'building…'}
            </div>
          </div>
          <div className="mm-stat" title="The 9-analyst roster cycles through every iteration. Each round brings a different lens (Optimist, Contrarian, Fundamentals, Growth, Hedging, Mental Models, PhD, Portfolio Modeling, Value).">
            <div className="mm-stat-label">{running ? 'Now' : 'Status'}</div>
            <div className="mm-stat-val" style={{fontSize: running ? 12 : 13, color: running ? '#00d4aa' : 'var(--accent)', textTransform: 'uppercase', letterSpacing: '0.04em'}}>
              {running ? (currentAnalyst || 'starting…') : 'DONE'}
            </div>
          </div>
        </div>

        {/* 9-analyst rotation strip — surfaces fix #9. Each chip is one of the
            brain/0-Agents/ personas. The active one glows; completed ones go
            green; pending ones stay dim. The user can see at a glance that
            Mad Max ISN'T just an optimist/contrarian flip but a full team
            rotation. */}
        <div className="mm-roster">
          {MM_ROSTER_LABELS.map((label, i) => {
            const fullCycle = Math.floor(iter / MM_ROSTER_LABELS.length);
            const inThisCycle = currentAnalystIdx;
            const isActive = i === inThisCycle && running;
            const isDone = fullCycle > 0 || (i < inThisCycle && iter > 0);
            return (
              <span
                key={label}
                className={`mm-roster-chip ${isActive ? 'active' : ''} ${isDone ? 'done' : ''}`}
                title={`${label} — ${isActive ? 'analyzing now' : isDone ? 'completed' : 'pending'}`}
              >
                {label}
              </span>
            );
          })}
        </div>

        <div className="mm-progress">
          <div className="mm-progress-fill" style={{width: `${finalPct}%`}}/>
        </div>

        <div className="mm-log" ref={scrollRef}>
          {log.map((row, i) => (
            <div key={i} className="mm-log-row">
              <div className="mm-log-time">{row.time}</div>
              <div className={`mm-log-tag tag-${row.tag}`}>{row.tag}</div>
              <div className="mm-log-text">{row.text}</div>
            </div>
          ))}
        </div>

        {result?.trade && (
          <div className="mm-result">
            <div className="mm-result-eyebrow">⌁ High-conviction trade · {result.trade.conviction || result.trade.convictionScore}/100</div>
            <div className="mm-result-title">{result.trade.name}</div>
            <div className="mm-result-body">{result.trade.thesis}</div>
            <div className="mm-result-actions">
              <button className="btn-ghost" onClick={onClose}>Review graph</button>
              {/* Phase 2A.3 — canonical Belief upgrade. Fetches the
                  backend's unified pipeline (log pool + Markov + γ-blend
                  + 80% CI + Kelly P_LB) and renders the BeliefCard below. */}
              <button className="btn-ghost" onClick={runCanonicalMath} disabled={beliefFetching}
                      title="Run /api/madmax/run for the canonical math sidecar (Brier-weighted pool + absorbing Markov chain + composite + CI + Kelly P_LB)">
                {beliefFetching
                  ? '⏳ math…'
                  : (belief ? '↻ refresh math' : 'ƒ run math')}
              </button>
              <button className="btn-accept" onClick={onAcceptTrade}>Send to chat →</button>
            </div>
            {beliefError && (
              <div style={{ marginTop: 6, color: 'var(--danger)', fontSize: 11,
                            fontFamily: "'JetBrains Mono', monospace" }}>
                ✕ {beliefError}
              </div>
            )}
            {belief && <BeliefCard belief={belief} />}
          </div>
        )}

        <div className="mm-foot">
          <div>{newSources}/{MIN_NEW_SOURCES} sources · round {progress.iter || 0}/{progress.max || 80}</div>
          <div className="mm-foot-model">DEEP RESEARCH · OPTIMIST vs CONTRARIAN</div>
        </div>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────
// Mad Max canonical pipeline — Phase 2A merge endpoint client.
// ─────────────────────────────────────────────────────────────────────
//
// `POST /api/madmax/run` is the unified backend pipeline that web, iOS,
// Android, and visionOS all share. The math (Brier-weighted log opinion
// pool + absorbing Markov chain + γ-blended composite + 80% CI + PAV
// isotonic calibration + Kelly with P_LB) lives ONLY on the backend.
//
// The local 9-analyst React debate loop in `runMadMax` above is the
// research sketch — it produces a `trade` with the model's subjective
// probability. To upgrade that to the canonical Belief, call this
// function with the analyst outputs and (optional) MD-style Markov
// edges.
//
// Returns the canonical Belief object. See backend/tibeb/madmax/types/
// belief.py and brain/0-Agents/schemas/Belief.format.md for the field
// schema.
async function runMadMaxViaBackend({
  hypothesis,
  analystProbabilities = null,
  perAnalystBrier = null,
  edges = null,
  startingState = null,
  fallbackProbability = null,
  gamma = 0.7,
  kellyAlpha = 0.25,
  kellyHardCap = 0.02,
  payoffRatio = 2.0,
}) {
  const payload = {
    hypothesis: hypothesis,
    gamma,
    kelly_alpha: kellyAlpha,
    kelly_hard_cap: kellyHardCap,
    payoff_ratio: payoffRatio,
  };
  if (Array.isArray(analystProbabilities)) {
    payload.analyst_probabilities = analystProbabilities;
  }
  if (perAnalystBrier && typeof perAnalystBrier === 'object') {
    payload.per_analyst_brier = Object.fromEntries(
      Object.entries(perAnalystBrier).map(([k, v]) => [k, [v[0], v[1]]])
    );
  }
  if (Array.isArray(edges) && edges.length) {
    payload.edges = edges.map(e => ({
      from: e.from, to: e.to, weight: e.weight,
      ...(e.sources ? { sources: e.sources } : {}),
    }));
  }
  if (startingState) payload.starting_state = startingState;
  if (fallbackProbability != null) payload.fallback_probability = fallbackProbability;

  const r = await fetch('/api/madmax/run', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
  if (!r.ok) {
    const text = await r.text().catch(() => '');
    throw new Error(`madmax/run ${r.status}: ${text.slice(0, 200)}`);
  }
  const j = await r.json();
  return j.belief;
}

/**
 * Convert a local Mad Max verdict (the `mmResult.trade` block from
 * `runMadMax`) into a canonical Belief by POSTing the analyst-derived
 * probability + the web's edges to /api/madmax/run.
 */
async function upgradeMadMaxToBelief(activeWeb, trade) {
  const hypo = activeWeb?.hypothesis || {};
  const hypoNode = (activeWeb?.nodes || []).find(n => n.id === 't_hypo' || n.isHypothesis);
  const startingState = hypoNode?.label || hypo.question || 'hypothesis';

  const referenceClass = {
    name: 'generic-uninformed',
    n: 0, k: 0, n_min_threshold: 30,
  };

  const hypothesisPayload = {
    text: hypo.question || activeWeb?.name || 'untitled hypothesis',
    resolution_fn: hypo.yesScenario || 'TBD — see web canvas',
    deadline: hypo.resolveBy || '2099-01-01T00:00:00Z',
    thesis_type: 'event-driven',
    horizon_bucket: '3-12mo',
    tickers: (activeWeb?.nodes || [])
      .filter(n => (n.type || '').toLowerCase() === 'ticker')
      .map(n => (n.label || '').replace(/^\$/, '').toUpperCase())
      .filter(Boolean),
    reference_class: referenceClass,
  };

  const edges = (activeWeb?.edges || [])
    .filter(e => typeof e.weight === 'number')
    .map(e => ({ from: e.from, to: e.to, weight: e.weight, sources: e.weight_sources }));

  const fallbackProbability = typeof trade?.probability === 'number'
    ? Math.max(0, Math.min(1, trade.probability / 100))
    : 0.5;

  return runMadMaxViaBackend({
    hypothesis: hypothesisPayload,
    analystProbabilities: [fallbackProbability],
    edges: edges.length ? edges : null,
    startingState,
    fallbackProbability,
  });
}

Object.assign(window, {
  runMadMax,
  MadMaxOverlay,
  // Phase 2A — backend Belief client (THE MERGE)
  runMadMaxViaBackend,
  upgradeMadMaxToBelief,
});
