// ThoughtGraph: force-directed canvas with drag, zoom, click, multi-select
const { useEffect, useRef, useState, useCallback } = React;

const NODE_COLORS = {
  thesis:   '#00d4aa',
  ticker:   '#e8e8ed',
  catalyst: '#ffaa33',
  source:   '#55556a',
};
const NODE_RADIUS = {
  thesis:   44,
  ticker:   26,
  catalyst: 22,
  source:   12,
};

function ThoughtGraph({ nodes: seedNodes, edges, selected, setSelected, focused, setFocused, hoveredEdgeKind, onShortcut, webId, onConnect }) {
  const canvasRef = useRef(null);
  const wrapRef = useRef(null);
  const stateRef = useRef({
    nodes: seedNodes.map(n => ({
      ...n,
      vx: 0, vy: 0,
      x: n.x ?? (Math.random() * 400 - 200),
      y: n.y ?? (Math.random() * 400 - 200),
    })),
    edges,
    cam: { x: 0, y: 0, k: 1 },
    drag: null,
    pan: null,
    hover: null,
    pointer: null,
    alpha: 1,        // simulation energy — cools to 0 like d3-force / Obsidian
    alphaTarget: 0,
    ticks: 0,
  });
  const [, force] = useState(0);
  // Track which web we last laid out for. When this flips, we DON'T carry
  // over positions from the prior web — webs share node IDs (e.g. `t_hypo`,
  // `t1`) and inheriting positions across web boundaries was sending the
  // new web's nodes far off-screen. Fresh layout per web.
  const lastWebIdRef = useRef(webId);

  // Compute a camera that fits the web inside the visible canvas with small
  // padding, hypothesis-centered if there is one.
  //
  // For dense webs (Mad Max sweeps with 100-200+ nodes): we DON'T fit the
  // full bbox — it would zoom so far out that every node becomes a dot.
  // Instead we fit to the dense core around the hypothesis (nearest 60%
  // of nodes by distance from the hypothesis). Outliers stay reachable by
  // panning, but the initial view is tight and readable.
  const fitCameraToNodes = (nodesArr) => {
    const canvas = canvasRef.current;
    const W = (canvas?.clientWidth) || 800;
    const H = ((canvas?.clientHeight) || 600) - 60;  // legend + hint
    if (!nodesArr || !nodesArr.length) return { x: 0, y: 0, k: 1 };

    const anchor = nodesArr.find(n => n.isHypothesis)
                || nodesArr.find(n => n.id === 't_hypo')
                || nodesArr[0];

    // For large webs, only fit the densest 60% of nodes (closest to anchor)
    // so the camera doesn't zoom out to chase outliers.
    const sorted = [...nodesArr].sort((a, b) => {
      const da = (a.x - anchor.x) ** 2 + (a.y - anchor.y) ** 2;
      const db = (b.x - anchor.x) ** 2 + (b.y - anchor.y) ** 2;
      return da - db;
    });
    const fitCount = nodesArr.length > 30
      ? Math.max(20, Math.round(nodesArr.length * 0.6))
      : nodesArr.length;
    const fitNodes = sorted.slice(0, fitCount);

    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
    for (const n of fitNodes) {
      const r = NODE_RADIUS[n.type] || 12;
      minX = Math.min(minX, n.x - r);
      minY = Math.min(minY, n.y - r);
      maxX = Math.max(maxX, n.x + r);
      maxY = Math.max(maxY, n.y + r);
    }
    const w = Math.max(1, maxX - minX);
    const h = Math.max(1, maxY - minY);
    const pad = 80;
    const kRaw = Math.min((W - pad * 2) / w, (H - pad * 2) / h);
    // Hold zoom in a readable band — never zoom in past 1.0× (no magnify)
    // and never below 0.45× (so 200-node webs stay legible, not dust).
    const k = Math.max(0.45, Math.min(1.0, kRaw));

    return { x: -anchor.x * k, y: -anchor.y * k, k };
  };

  // Sync stateRef when web swaps (seedNodes/edges identity changes)
  useEffect(() => {
    const s = stateRef.current;
    const isWebSwitch = (webId !== undefined) && (webId !== lastWebIdRef.current);
    lastWebIdRef.current = webId;
    // On web switch, throw away the prior web's positions. Otherwise
    // (intra-web edits, Mad Max additions, etc.) keep them so the user's
    // hand-tuned layout doesn't reset on every node mutation.
    const prevById = isWebSwitch ? new Map() : new Map(s.nodes.map(n => [n.id, n]));
    s.nodes = seedNodes.map((n, i) => {
      const prev = prevById.get(n.id);
      if (prev) return { ...n, vx: prev.vx, vy: prev.vy, x: prev.x, y: prev.y };
      // For new nodes (or all nodes after a web switch): respect the node's
      // own stored x/y if present — that's the seed/localStorage layout —
      // and fall back to a deterministic ring otherwise.
      const angle = (i / Math.max(1, seedNodes.length)) * Math.PI * 2 + Math.random() * 0.4;
      const radius = 220 + Math.random() * 80;
      return {
        ...n, vx: 0, vy: 0,
        x: typeof n.x === 'number' ? n.x : Math.cos(angle) * radius,
        y: typeof n.y === 'number' ? n.y : Math.sin(angle) * radius,
      };
    });
    s.edges = edges;
    s.ticks = 0;
    if (isWebSwitch) {
      // Fresh web: don't perturb the loaded layout. Cold alpha + zero
      // velocities so the physics doesn't drag anything around — the seed
      // positions are intentional. Drag/hover state from the previous web
      // is also cleared so a half-finished interaction doesn't carry over.
      s.alpha = 0;
      s.alphaTarget = 0;
      for (const n of s.nodes) { n.vx = 0; n.vy = 0; }
      s.drag = null;
      s.pan = null;
      s.hover = null;
      s.cam = fitCameraToNodes(s.nodes);
    } else {
      // Intra-web update (Mad Max added nodes, user edited, etc.) — re-heat
      // alpha so the new nodes find their place. Keep current camera.
      s.alpha = Math.max(s.alpha, 0.6);
    }
    force(v => v + 1);
  }, [seedNodes, edges, webId]);

  // Physics + render loop
  useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    let raf;

    const resize = () => {
      const rect = wrapRef.current.getBoundingClientRect();
      const dpr = window.devicePixelRatio || 1;
      canvas.width = rect.width * dpr;
      canvas.height = rect.height * dpr;
      canvas.style.width = rect.width + 'px';
      canvas.style.height = rect.height + 'px';
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(wrapRef.current);

    const tick = () => {
      const s = stateRef.current;
      const W = canvas.clientWidth, H = canvas.clientHeight;
      const cam = s.cam;
      s.ticks++;

      // ─── Obsidian-style cooling simulation ───
      // alpha decays from 1 → 0 then sticks. While dragging we keep it warm.
      // Below alphaMin we still render but skip physics entirely (graph is settled, no drift).
      const alphaDecay = 0.012;     // ~1 / (1/0.012) ≈ 83 ticks to cool
      const alphaMin = 0.005;
      if (s.drag) s.alpha = Math.max(s.alpha, 0.4);
      const settled = s.alpha < alphaMin;
      if (!settled) s.alpha += (s.alphaTarget - s.alpha) * alphaDecay;

      if (!settled) {
        const a = s.alpha;
        // Center pull — very gentle, scaled by alpha so it doesn't trickle when settled
        const centerK = 0.012 * a;
        for (const n of s.nodes) {
          if (s.drag && s.drag.id === n.id) continue;
          n.vx += (-n.x) * centerK;
          n.vy += (-n.y) * centerK;
        }

        // Repulsion — capped, linear falloff (not 1/r² which causes the "flying")
        // Only acts within a local neighborhood, like Obsidian's many-body force.
        const repulseRange = 240;
        const repulseStrength = 50 * a;
        for (let i = 0; i < s.nodes.length; i++) {
          for (let j = i + 1; j < s.nodes.length; j++) {
            const A = s.nodes[i], B = s.nodes[j];
            const dx = B.x - A.x, dy = B.y - A.y;
            const d2 = dx*dx + dy*dy + 0.01;
            const d = Math.sqrt(d2);
            if (d < repulseRange) {
              // Linear falloff: full strength at distance 0, zero at repulseRange
              const f = repulseStrength * (1 - d / repulseRange) / d;
              const fx = dx * f;
              const fy = dy * f;
              if (!(s.drag && s.drag.id === A.id)) { A.vx -= fx; A.vy -= fy; }
              if (!(s.drag && s.drag.id === B.id)) { B.vx += fx; B.vy += fy; }
            }
            // Hard collision (always on, even when settled — but applied positionally so no momentum)
            const ra = NODE_RADIUS[A.type], rb = NODE_RADIUS[B.type];
            const minD = ra + rb + 6;
            if (d < minD) {
              const overlap = (minD - d) / 2;
              const ox = (dx/d) * overlap;
              const oy = (dy/d) * overlap;
              if (!(s.drag && s.drag.id === A.id)) { A.x -= ox; A.y -= oy; }
              if (!(s.drag && s.drag.id === B.id)) { B.x += ox; B.y += oy; }
            }
          }
        }

        // Spring edges — gentle, alpha-scaled
        const springK = 0.08 * a;
        const restLength = 130;
        for (const e of s.edges) {
          const A = s.nodes.find(n => n.id === e.from);
          const B = s.nodes.find(n => n.id === e.to);
          if (!A || !B) continue;
          const dx = B.x - A.x, dy = B.y - A.y;
          const d = Math.sqrt(dx*dx+dy*dy) + 0.01;
          const f = (d - restLength) * springK;
          const fx = (dx/d) * f, fy = (dy/d) * f;
          if (!(s.drag && s.drag.id === A.id)) { A.vx += fx; A.vy += fy; }
          if (!(s.drag && s.drag.id === B.id)) { B.vx -= fx; B.vy -= fy; }
        }

        // Integrate with strong velocity damping (Obsidian-feel)
        const damping = 0.4;  // d3-force default; very draggy → no flying
        for (const n of s.nodes) {
          if (s.drag && s.drag.id === n.id) { n.vx = 0; n.vy = 0; continue; }
          n.vx *= damping;
          n.vy *= damping;
          n.x += n.vx;
          n.y += n.vy;
        }
      } else {
        // Settled — make sure leftover velocity doesn't keep nudging things
        for (const n of s.nodes) { n.vx = 0; n.vy = 0; }
      }

      // Render
      ctx.clearRect(0, 0, W, H);

      // Grid lines
      ctx.save();
      const gridSize = 80 * cam.k;
      const ox = ((W/2 + cam.x) % gridSize + gridSize) % gridSize;
      const oy = ((H/2 + cam.y) % gridSize + gridSize) % gridSize;
      ctx.strokeStyle = 'rgba(30, 30, 40, 0.6)';
      ctx.lineWidth = 1;
      ctx.beginPath();
      for (let x = ox; x < W; x += gridSize) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
      for (let y = oy; y < H; y += gridSize) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
      ctx.stroke();
      ctx.restore();

      // World transform
      ctx.save();
      ctx.translate(W/2 + cam.x, H/2 + cam.y);
      ctx.scale(cam.k, cam.k);

      // Edges
      for (const e of s.edges) {
        const a = s.nodes.find(n => n.id === e.from);
        const b = s.nodes.find(n => n.id === e.to);
        if (!a || !b) continue;
        const isFocusedEdge = focused && (focused === a.id || focused === b.id);
        const isContradict = e.kind === 'contradicts';
        const baseColor = isContradict ? 'rgba(255, 68, 102, ALPHA)' : 'rgba(0, 212, 170, ALPHA)';
        const dim = isContradict ? 0.16 : 0.10;
        const lit = isContradict ? 0.6 : 0.55;
        ctx.strokeStyle = baseColor.replace('ALPHA', isFocusedEdge ? lit : dim);
        ctx.lineWidth = isFocusedEdge ? 1.4 : 1;
        if (isContradict) ctx.setLineDash([5, 4]); else ctx.setLineDash([]);
        ctx.beginPath();
        ctx.moveTo(a.x, a.y);
        ctx.lineTo(b.x, b.y);
        ctx.stroke();

        if (isFocusedEdge) {
          // Edge label
          const mx = (a.x + b.x) / 2;
          const my = (a.y + b.y) / 2;
          ctx.setLineDash([]);
          ctx.font = '600 9px "JetBrains Mono", monospace';
          const labelText = e.kind.toUpperCase();
          const w = ctx.measureText(labelText).width + 14;
          ctx.fillStyle = '#0a0a0c';
          ctx.fillRect(mx - w/2, my - 8, w, 16);
          ctx.strokeStyle = isContradict ? '#ff4466' : '#00d4aa';
          ctx.lineWidth = 1;
          ctx.strokeRect(mx - w/2, my - 8, w, 16);
          ctx.fillStyle = isContradict ? '#ff4466' : '#00d4aa';
          ctx.textAlign = 'center';
          ctx.textBaseline = 'middle';
          ctx.fillText(labelText, mx, my);
        }
      }
      ctx.setLineDash([]);

      // Tether (Shift-drag connect) — dashed line from source node to
      // current cursor world-position, with a target ring when hovering on
      // a different node so the user knows where the edge will land.
      if (s.tether) {
        const from = s.nodes.find(nn => nn.id === s.tether.fromId);
        if (from) {
          ctx.save();
          ctx.strokeStyle = '#00d4aa';
          ctx.lineWidth = 1.6;
          ctx.setLineDash([6, 4]);
          ctx.beginPath();
          ctx.moveTo(from.x, from.y);
          ctx.lineTo(s.tether.mx, s.tether.my);
          ctx.stroke();
          ctx.setLineDash([]);

          // Mouse-end dot
          ctx.fillStyle = '#00d4aa';
          ctx.beginPath();
          ctx.arc(s.tether.mx, s.tether.my, 4, 0, Math.PI * 2);
          ctx.fill();

          // Highlight ring on the snap target (if we're over a node)
          if (s.tether.targetId) {
            const tgt = s.nodes.find(nn => nn.id === s.tether.targetId);
            if (tgt) {
              const tr = (NODE_RADIUS[tgt.type] || 18) + 8;
              ctx.strokeStyle = '#00d4aa';
              ctx.lineWidth = 2;
              ctx.beginPath();
              ctx.arc(tgt.x, tgt.y, tr, 0, Math.PI * 2);
              ctx.stroke();
            }
          }
          ctx.restore();
        }
      }

      // Nodes
      for (const n of s.nodes) {
        const r = NODE_RADIUS[n.type];
        const color = NODE_COLORS[n.type];
        const isSelected = selected.includes(n.id);
        const isFocused = focused === n.id;
        const isHover = s.hover === n.id;

        // Glow for focused/selected
        if (isSelected || isFocused) {
          const grad = ctx.createRadialGradient(n.x, n.y, r, n.x, n.y, r + 28);
          grad.addColorStop(0, color + '40');
          grad.addColorStop(1, color + '00');
          ctx.fillStyle = grad;
          ctx.beginPath();
          ctx.arc(n.x, n.y, r + 28, 0, Math.PI * 2);
          ctx.fill();
        }

        // Render per-type — flat shapes, no glassy fills
        if (n.type === 'thesis') {
          // Hollow ring with accent + dark core
          ctx.fillStyle = '#0a0a0c';
          ctx.beginPath();
          ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
          ctx.fill();
          ctx.strokeStyle = color;
          ctx.lineWidth = isFocused || isHover ? 2 : 1.4;
          ctx.beginPath();
          ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
          ctx.stroke();
          // Hypothesis node: distinguish with a doubled ring + status tint
          if (n.isHypothesis) {
            const statusColor = n.hypothesisStatus === 'yes' ? '#00d4aa'
                              : n.hypothesisStatus === 'no'  ? '#ff4466'
                              : n.hypothesisStatus === 'undecided' ? '#d4b800'
                              : '#00d4aa';
            ctx.strokeStyle = statusColor;
            ctx.lineWidth = 1;
            ctx.beginPath();
            ctx.arc(n.x, n.y, r + 5, 0, Math.PI * 2);
            ctx.stroke();
            // Tiny status pip at top
            ctx.fillStyle = statusColor;
            ctx.beginPath();
            ctx.arc(n.x, n.y - r - 5, 3, 0, Math.PI * 2);
            ctx.fill();
          }
          // Tiny inner dot
          ctx.fillStyle = color;
          ctx.beginPath();
          ctx.arc(n.x, n.y - r + 8, 2, 0, Math.PI * 2);
          ctx.fill();
        } else if (n.type === 'ticker') {
          // Square tag with monospace symbol
          const half = r;
          ctx.fillStyle = isFocused || isHover ? color : '#16161c';
          ctx.fillRect(n.x - half, n.y - half + 4, half * 2, half * 2 - 8);
          ctx.strokeStyle = color;
          ctx.lineWidth = 1.2;
          ctx.strokeRect(n.x - half, n.y - half + 4, half * 2, half * 2 - 8);
        } else if (n.type === 'catalyst') {
          // Diamond
          ctx.fillStyle = '#0a0a0c';
          ctx.beginPath();
          ctx.moveTo(n.x, n.y - r);
          ctx.lineTo(n.x + r, n.y);
          ctx.lineTo(n.x, n.y + r);
          ctx.lineTo(n.x - r, n.y);
          ctx.closePath();
          ctx.fill();
          ctx.strokeStyle = color;
          ctx.lineWidth = isFocused || isHover ? 2 : 1.4;
          ctx.stroke();
        } else if (n.type === 'source') {
          // Small filled dot
          ctx.fillStyle = isFocused || isHover ? '#e8e8ed' : color;
          ctx.beginPath();
          ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
          ctx.fill();
        }

        // Selection ring (outer corner-marks)
        if (isSelected) {
          ctx.strokeStyle = '#00d4aa';
          ctx.lineWidth = 1.4;
          const cr = r + 8;
          const cl = 5;
          // 4 corner ticks
          ctx.beginPath();
          // TL
          ctx.moveTo(n.x - cr, n.y - cr + cl); ctx.lineTo(n.x - cr, n.y - cr); ctx.lineTo(n.x - cr + cl, n.y - cr);
          // TR
          ctx.moveTo(n.x + cr - cl, n.y - cr); ctx.lineTo(n.x + cr, n.y - cr); ctx.lineTo(n.x + cr, n.y - cr + cl);
          // BR
          ctx.moveTo(n.x + cr, n.y + cr - cl); ctx.lineTo(n.x + cr, n.y + cr); ctx.lineTo(n.x + cr - cl, n.y + cr);
          // BL
          ctx.moveTo(n.x - cr + cl, n.y + cr); ctx.lineTo(n.x - cr, n.y + cr); ctx.lineTo(n.x - cr, n.y + cr - cl);
          ctx.stroke();
        }

        // Label inside ticker
        if (n.type === 'ticker') {
          ctx.fillStyle = isFocused || isHover ? '#0a0a0c' : color;
          // Auto-fit symbol inside the bubble
          const maxW = (r * 2) - 12;
          let fontPx = 12;
          ctx.font = `700 ${fontPx}px "JetBrains Mono", monospace`;
          while (ctx.measureText(n.label).width > maxW && fontPx > 8) {
            fontPx -= 0.5;
            ctx.font = `700 ${fontPx}px "JetBrains Mono", monospace`;
          }
          ctx.textAlign = 'center';
          ctx.textBaseline = 'middle';
          ctx.fillText(n.label, n.x, n.y + 1);
        } else if (n.type === 'thesis') {
          // Simple, robust wrap inside the ring. Max 3 lines, ellipsize last.
          ctx.fillStyle = '#e8e8ed';
          ctx.textAlign = 'center';
          ctx.textBaseline = 'middle';
          const maxLines = 3;
          // Effective width: a bit less than diameter so words don't kiss the ring
          const maxW = r * 1.55;
          // Pick font size based on label length
          const len = (n.label || '').length;
          const fontPx = len > 36 ? 9.5 : len > 24 ? 10 : 10.5;
          ctx.font = `500 ${fontPx}px "DM Sans", sans-serif`;
          const lineH = fontPx * 1.2;
          const words = (n.label || '').split(/\s+/).filter(Boolean);
          const lines = [];
          let line = '';
          for (let i = 0; i < words.length && lines.length < maxLines; i++) {
            const w = words[i];
            const test = line ? line + ' ' + w : w;
            if (ctx.measureText(test).width > maxW && line) {
              lines.push(line);
              line = w;
            } else {
              line = test;
            }
          }
          if (line && lines.length < maxLines) lines.push(line);
          // If we dropped words, ellipsize last line
          const consumed = lines.join(' ').split(/\s+/).filter(Boolean).length;
          if (consumed < words.length && lines.length) {
            let s = lines[lines.length - 1];
            while (ctx.measureText(s + '…').width > maxW && s.length > 0) s = s.slice(0, -1);
            lines[lines.length - 1] = s + '…';
          }
          const totalH = lines.length * lineH;
          lines.forEach((l, i) => {
            const y = n.y - totalH/2 + lineH/2 + i * lineH;
            ctx.fillText(l, n.x, y);
          });
        }

        // External label below (catalyst & source)
        const drawExternalLabel = (text, color, font, maxLines = 2) => {
          ctx.fillStyle = color;
          ctx.font = font;
          ctx.textAlign = 'center';
          ctx.textBaseline = 'top';
          const maxW = 140;
          const words = (text || '').split(/\s+/).filter(Boolean);
          const lines = [];
          let line = '';
          for (let i = 0; i < words.length && lines.length < maxLines; i++) {
            const w = words[i];
            const test = line ? line + ' ' + w : w;
            if (ctx.measureText(test).width > maxW && line) {
              lines.push(line);
              line = w;
            } else line = test;
          }
          if (line && lines.length < maxLines) lines.push(line);
          const consumed = lines.join(' ').split(/\s+/).filter(Boolean).length;
          if (consumed < words.length && lines.length) {
            let s = lines[lines.length - 1];
            while (ctx.measureText(s + '…').width > maxW && s.length > 0) s = s.slice(0, -1);
            lines[lines.length - 1] = s + '…';
          }
          const lineH = 13;
          lines.forEach((l, i) => ctx.fillText(l, n.x, n.y + r + 8 + i * lineH));
        };

        if (n.type === 'catalyst') {
          drawExternalLabel(
            n.label,
            isFocused || isHover ? '#e8e8ed' : '#9090a8',
            '500 11px "DM Sans", sans-serif',
            2
          );
        } else if (n.type === 'source') {
          drawExternalLabel(
            n.label,
            isFocused || isHover ? '#e8e8ed' : '#7a7a92',
            '500 10.5px "DM Sans", sans-serif',
            2
          );
        }

        if (n.type === 'ticker') {
          ctx.fillStyle = n.change >= 0 ? '#00d4aa' : '#ff4466';
          ctx.font = '500 10px "JetBrains Mono", monospace';
          ctx.textAlign = 'center';
          ctx.textBaseline = 'top';
          ctx.fillText((n.change >= 0 ? '+' : '') + n.change.toFixed(2) + '%', n.x, n.y + r + 4);
        }
      }

      ctx.restore();

      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);

    return () => { cancelAnimationFrame(raf); ro.disconnect(); };
  }, [selected, focused]);

  // Convert screen -> world
  const toWorld = (sx, sy) => {
    const s = stateRef.current;
    const rect = canvasRef.current.getBoundingClientRect();
    const x = (sx - rect.left - rect.width/2 - s.cam.x) / s.cam.k;
    const y = (sy - rect.top - rect.height/2 - s.cam.y) / s.cam.k;
    return { x, y };
  };

  const hitTest = (wx, wy) => {
    const s = stateRef.current;
    for (let i = s.nodes.length - 1; i >= 0; i--) {
      const n = s.nodes[i];
      const r = NODE_RADIUS[n.type];
      const dx = wx - n.x, dy = wy - n.y;
      if (dx*dx + dy*dy <= r*r) return n;
    }
    return null;
  };

  const onMouseDown = (e) => {
    const { x, y } = toWorld(e.clientX, e.clientY);
    const n = hitTest(x, y);
    const s = stateRef.current;
    if (n) {
      // Shift-drag from a node = Obsidian-style "tether" — drag a line out
      // and release on another node to create an edge between them.
      if (e.shiftKey) {
        s.tether = { fromId: n.id, mx: x, my: y };
        canvasRef.current.style.cursor = 'crosshair';
      } else {
        s.drag = { id: n.id, dx: x - n.x, dy: y - n.y, moved: false };
        s.alpha = Math.max(s.alpha, 0.6);  // re-heat so neighbors readjust
      }
    } else {
      s.pan = { sx: e.clientX, sy: e.clientY, cx: s.cam.x, cy: s.cam.y };
    }
  };

  const onMouseMove = (e) => {
    const s = stateRef.current;
    const { x, y } = toWorld(e.clientX, e.clientY);
    if (s.tether) {
      // Track the cursor so the dashed line follows the mouse, and
      // highlight whichever node it's hovering on as the connection target.
      s.tether.mx = x;
      s.tether.my = y;
      const target = hitTest(x, y);
      s.tether.targetId = (target && target.id !== s.tether.fromId) ? target.id : null;
    } else if (s.drag) {
      const node = s.nodes.find(n => n.id === s.drag.id);
      if (node) {
        node.x = x - s.drag.dx;
        node.y = y - s.drag.dy;
        s.drag.moved = true;
      }
    } else if (s.pan) {
      s.cam.x = s.pan.cx + (e.clientX - s.pan.sx);
      s.cam.y = s.pan.cy + (e.clientY - s.pan.sy);
    } else {
      const n = hitTest(x, y);
      const newHover = n ? n.id : null;
      if (s.hover !== newHover) {
        s.hover = newHover;
        canvasRef.current.style.cursor = n ? 'pointer' : 'grab';
      }
    }
  };

  const onMouseUp = (e) => {
    const s = stateRef.current;
    if (s.tether) {
      // Release the tether — if we landed on another node, fire onConnect
      // and let app.jsx decide the edge kind / append it to setEdges.
      const fromId = s.tether.fromId;
      const toId   = s.tether.targetId;
      if (fromId && toId && fromId !== toId && typeof onConnect === 'function') {
        onConnect(fromId, toId);
      }
      s.tether = null;
      canvasRef.current.style.cursor = 'grab';
    } else if (s.drag && !s.drag.moved) {
      const id = s.drag.id;
      if (e.metaKey || e.ctrlKey) {
        setSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
      } else {
        setFocused(id);
        setSelected([id]);
      }
    } else if (s.pan && !s.drag) {
      // click on empty -> clear focus
      const moved = Math.hypot(e.clientX - s.pan.sx, e.clientY - s.pan.sy);
      if (moved < 4 && !e.metaKey) {
        setFocused(null);
        setSelected([]);
      }
    }
    s.drag = null;
    s.pan = null;
  };

  const ZOOM_MIN = 0.12;   // pull way out for 100+ node Mad Max sweeps
  const ZOOM_MAX = 4.0;    // zoom in close enough to read source excerpts

  const onWheel = (e) => {
    e.preventDefault();
    const s = stateRef.current;
    const rect = canvasRef.current.getBoundingClientRect();
    const mx = e.clientX - rect.left - rect.width/2;
    const my = e.clientY - rect.top - rect.height/2;
    const wx = (mx - s.cam.x) / s.cam.k;
    const wy = (my - s.cam.y) / s.cam.k;
    // Trackpad pinch-zoom on macOS comes through as wheel + ctrlKey — heavier steps for it.
    const sensitivity = e.ctrlKey ? 0.0040 : 0.0015;
    const factor = Math.exp(-e.deltaY * sensitivity);
    const nk = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, s.cam.k * factor));
    s.cam.k = nk;
    s.cam.x = mx - wx * nk;
    s.cam.y = my - wy * nk;
  };

  // Programmatic zoom (used by the on-screen ± / ⊙ buttons). Anchors at center.
  const zoomBy = (factor) => {
    const s = stateRef.current;
    s.cam.k = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, s.cam.k * factor));
  };
  // Middle button — pan the camera to the hypothesis (central thesis) node
  // and focus it. Keeps the current zoom level so the user can dial in a
  // wide view and still snap back to the anchor. Falls back to a clean
  // origin/zoom reset if no hypothesis node exists yet.
  const centerOnHypothesis = () => {
    const s = stateRef.current;
    const hypo = s.nodes.find(n => n.isHypothesis) || s.nodes.find(n => n.id === 't_hypo');
    if (!hypo) {
      s.cam = { x: 0, y: 0, k: 1 };
      return;
    }
    const targetX = -hypo.x * s.cam.k;
    const targetY = -hypo.y * s.cam.k;
    const startX = s.cam.x, startY = s.cam.y;
    let frame = 0;
    const tick = () => {
      frame++;
      const t = Math.min(1, frame / 30);
      const ease = 1 - Math.pow(1 - t, 3);
      s.cam.x = startX + (targetX - startX) * ease;
      s.cam.y = startY + (targetY - startY) * ease;
      if (t < 1) requestAnimationFrame(tick);
    };
    tick();
    // Highlight it too, so the user sees the glow + selection corners.
    setFocused(hypo.id);
  };

  const onContext = (e) => {
    e.preventDefault();
    const { x, y } = toWorld(e.clientX, e.clientY);
    const n = hitTest(x, y);
    if (n) {
      onShortcut('contextmenu', { node: n, screenX: e.clientX, screenY: e.clientY });
    }
  };

  // Focus on a node externally (jump-to from chat citation)
  useEffect(() => {
    if (!focused) return;
    const s = stateRef.current;
    const node = s.nodes.find(n => n.id === focused);
    if (!node) return;
    const W = canvasRef.current.clientWidth;
    const H = canvasRef.current.clientHeight;
    const targetX = -node.x * s.cam.k;
    const targetY = -node.y * s.cam.k;
    let frame = 0;
    const startX = s.cam.x, startY = s.cam.y;
    const animate = () => {
      frame++;
      const t = Math.min(1, frame / 30);
      const ease = 1 - Math.pow(1 - t, 3);
      s.cam.x = startX + (targetX - startX) * ease;
      s.cam.y = startY + (targetY - startY) * ease;
      if (t < 1) requestAnimationFrame(animate);
    };
    animate();
  }, [focused]);

  return (
    <div ref={wrapRef} className="graph-wrap">
      <canvas
        ref={canvasRef}
        onMouseDown={onMouseDown}
        onMouseMove={onMouseMove}
        onMouseUp={onMouseUp}
        onMouseLeave={onMouseUp}
        onWheel={onWheel}
        onContextMenu={onContext}
      />
      <div className="graph-legend">
        <div className="legend-item"><span className="dot" style={{background:NODE_COLORS.thesis, border:'1px solid '+NODE_COLORS.thesis, background:'transparent'}}/>THESIS</div>
        <div className="legend-item"><span className="dot" style={{background:NODE_COLORS.ticker, borderRadius:0}}/>TICKER</div>
        <div className="legend-item"><span className="dot" style={{background:NODE_COLORS.catalyst, transform:'rotate(45deg)'}}/>CATALYST</div>
        <div className="legend-item"><span className="dot" style={{background:NODE_COLORS.source}}/>SOURCE</div>
        <div className="legend-sep" />
        <div className="legend-item"><span className="line solid"/>SUPPORTS</div>
        <div className="legend-item"><span className="line dashed"/>CONTRADICTS</div>
      </div>
      <div className="graph-zoom">
        <button className="zoom-btn" onClick={() => zoomBy(1.25)} title="Zoom in (or scroll up)">+</button>
        <button className="zoom-btn" onClick={centerOnHypothesis} title="Center on hypothesis · ⊙">⊙</button>
        <button className="zoom-btn" onClick={() => zoomBy(0.8)} title="Zoom out (or scroll down)">−</button>
      </div>
      <div className="graph-hint">
        <kbd>DRAG</kbd> · <kbd>SCROLL</kbd> · <kbd>PINCH</kbd> · <kbd>⌘ CLICK</kbd> · <kbd>RIGHT-CLICK</kbd>
      </div>
    </div>
  );
}

window.ThoughtGraph = ThoughtGraph;
window.NODE_COLORS = NODE_COLORS;
