// Fabric — Graph canvas & node renderers (GraphCanvas, cards, ConceptNode). Split out of fabric_graph.jsx.
const { useState: useGc, useEffect: useGcE, useRef: useGcR, useMemo: useGcM } = React;

function GraphCanvas({ layout, visibleEdges, selected, onSelect, onBridgeJump, view, focusTarget, onFocused, pulseId, morphNonce }) {
  const svgRef = useGcR(null);
  const [viewBox, setViewBox] = useGc(() => ({ x: 0, y: 0, w: layout.width, h: layout.height }));

  // Reset viewBox on layout change (tab switch or re-layout)
  useGcE(() => {
    setViewBox({ x: 0, y: 0, w: layout.width, h: layout.height });
  }, [layout]);

  // Bridge jump: center viewBox on target
  useGcE(() => {
    if (!focusTarget) return;
    setViewBox(vb => ({
      ...vb,
      x: focusTarget.x - vb.w / 2,
      y: focusTarget.y - vb.h / 2,
    }));
    if (onFocused) onFocused();
  }, [focusTarget]);

  // Wheel zoom — native listener for preventDefault
  useGcE(() => {
    const svg = svgRef.current;
    if (!svg) return;
    const onWheel = (e) => {
      e.preventDefault();
      const rect = svg.getBoundingClientRect();
      const mx = (e.clientX - rect.left) / Math.max(1, rect.width);
      const my = (e.clientY - rect.top)  / Math.max(1, rect.height);
      const ratio = e.deltaY > 0 ? 1.12 : 0.89;
      setViewBox(vb => {
        const newW = Math.max(120, Math.min(layout.width * 3, vb.w * ratio));
        const eff = newW / vb.w;
        const newH = vb.h * eff;
        const px = vb.x + mx * vb.w;
        const py = vb.y + my * vb.h;
        return { x: px - mx * newW, y: py - my * newH, w: newW, h: newH };
      });
    };
    svg.addEventListener('wheel', onWheel, { passive: false });
    return () => svg.removeEventListener('wheel', onWheel);
  }, [layout.width, layout.height]);

  const dragRef = useGcR({ active: false, startX: 0, startY: 0, startVB: null });
  const [dragging, setDragging] = useGc(false);
  const onMouseDown = (e) => {
    if (e.target.closest('[data-node]')) return;
    dragRef.current = { active: true, startX: e.clientX, startY: e.clientY, startVB: viewBox };
    setDragging(true);
  };
  const onMouseMove = (e) => {
    const d = dragRef.current;
    if (!d.active) return;
    const svg = svgRef.current;
    if (!svg) return;
    const rect = svg.getBoundingClientRect();
    const dx = (e.clientX - d.startX) / Math.max(1, rect.width)  * d.startVB.w;
    const dy = (e.clientY - d.startY) / Math.max(1, rect.height) * d.startVB.h;
    setViewBox({ x: d.startVB.x - dx, y: d.startVB.y - dy, w: d.startVB.w, h: d.startVB.h });
  };
  const onMouseUp = () => {
    if (dragRef.current.active) {
      dragRef.current.active = false;
      setDragging(false);
    }
  };

  const fit = () => setViewBox({ x: 0, y: 0, w: layout.width, h: layout.height });
  const zoomBtn = (ratio) => setViewBox(vb => zoomAtCenter(vb, ratio, layout));

  // Neighborhood highlight
  const nbhd = useGcM(() => {
    if (!selected) return null;
    const s = new Set([selected.id]);
    visibleEdges.forEach(e => {
      if (e.source === selected.id) s.add(e.target);
      else if (e.target === selected.id) s.add(e.source);
    });
    return s;
  }, [selected, visibleEdges]);

  const microAll = useGcM(() => {
    const arr = [];
    layout.microNodesByParent.forEach(children => arr.push(...children));
    if (layout.orphanChildren) arr.push(...layout.orphanChildren);
    return arr;
  }, [layout]);

  const onChipClick = (chipId) => {
    const n = layout.fullIndex.get(chipId);
    if (n) onSelect(n);
  };

  return (
    <div style={{ flex: 1, position: 'relative', overflow: 'hidden', background: 'var(--bg)' }}>
      <svg
        ref={svgRef}
        viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`}
        style={{ width: '100%', height: '100%', display: 'block', cursor: dragging ? 'grabbing' : 'grab' }}
        onMouseDown={onMouseDown}
        onMouseMove={onMouseMove}
        onMouseUp={onMouseUp}
        onMouseLeave={onMouseUp}
      >
        <defs>
          <pattern id="graphGrid" width="40" height="40" patternUnits="userSpaceOnUse">
            <circle cx="1" cy="1" r="0.7" fill="var(--border-subtle)"/>
          </pattern>
        </defs>
        <rect x={0} y={0} width={layout.width} height={layout.height} fill="url(#graphGrid)"/>

        {/* Dynamic content wrapped so a morph (key bump) replays the entrance
            animation; the grid above stays put. */}
        <g key={morphNonce} style={{ animation: morphNonce ? 'parcle-graph-in 700ms ease-out both' : undefined }}>
        {/* Edges */}
        {visibleEdges.map((e, i) => {
          const a = layout.nodeIndex.get(e.source);
          const b = layout.nodeIndex.get(e.target);
          if (!a || !b) return null;
          const dim = nbhd && !(nbhd.has(a.id) && nbhd.has(b.id));
          const col = EDGE_COLORS[e.type] || '#94A3B8';
          const isSubtle = e.type === 'structure' && !e._fk;
          const dashed = e.type === 'mapping' || e.type === 'relation' || e.type === 'category';
          return (
            <line key={`${e.type}|${e.source}|${e.target}|${i}`}
              x1={a.x} y1={a.y} x2={b.x} y2={b.y}
              stroke={col}
              strokeOpacity={dim ? 0.08 : (isSubtle ? 0.30 : 0.60)}
              strokeWidth={isSubtle ? 0.9 : 1.25}
              strokeDasharray={dashed ? '4 3' : '0'}/>
          );
        })}

        {/* Micro nodes (columns/chunks) */}
        {microAll.map(m => (
          <MicroDot key={m.id} node={m}
            selected={selected && selected.id === m.id}
            dim={nbhd && !nbhd.has(m.id)}
            view={view}
            onClick={() => onSelect(m)}/>
        ))}

        {/* Macro nodes */}
        {layout.macroNodes.map(n => {
          const isSel = selected && selected.id === n.id;
          const dim = nbhd && !nbhd.has(n.id);
          if (n.category === 'factual' && n.type === 'table') {
            const cols = layout.microNodesByParent.get(n.id) || [];
            return <TableCard key={n.id} node={n} columnCount={cols.length}
              selected={isSel} dim={dim} onClick={onSelect}/>;
          }
          if (n.category === 'factual' && n.type === 'document') {
            const chs = layout.microNodesByParent.get(n.id) || [];
            return <DocumentCard key={n.id} node={n} chunkCount={chs.length}
              selected={isSel} dim={dim} onClick={onSelect}/>;
          }
          return <ConceptNode key={n.id} node={n}
            selected={isSel} dim={dim}
            chips={layout.categoryChips.get(n.id) || []}
            isBridge={layout.bridges.has(n.id)}
            view={view}
            onClick={onSelect}
            onBridgeJump={onBridgeJump}
            onChipClick={onChipClick}/>;
        })}
        </g>

        {/* Pulse highlight ring on the freshly-learned concept (reveal sequence).
            Rendered outside the morph group so it always sits on top. */}
        {pulseId && (() => {
          // Only pulse a node that's actually rendered in this view (nodeIndex),
          // never a micro/value node that lives only in fullIndex.
          const pn = layout.nodeIndex.get(pulseId);
          if (!pn) return null;
          return (
            <g style={{ pointerEvents: 'none' }}>
              {/* Soft pulsing halo */}
              <circle cx={pn.x} cy={pn.y} r={34} fill="var(--accent)">
                <animate attributeName="opacity" values="0.26;0.08;0.26" dur="1.6s" repeatCount="indefinite"/>
                <animate attributeName="r" values="32;38;32" dur="1.6s" repeatCount="indefinite"/>
              </circle>
              {/* Bright steady ring */}
              <circle cx={pn.x} cy={pn.y} r={32} fill="none" stroke="var(--accent)" strokeWidth={4} opacity={0.95}/>
              {/* Two staggered expanding ripples for a continuous, obvious pulse */}
              <circle cx={pn.x} cy={pn.y} r={28} fill="none" stroke="var(--accent)" strokeWidth={4}>
                <animate attributeName="r" from="28" to="72" dur="1.5s" repeatCount="indefinite"/>
                <animate attributeName="opacity" from="0.85" to="0" dur="1.5s" repeatCount="indefinite"/>
                <animate attributeName="stroke-width" from="4" to="0.5" dur="1.5s" repeatCount="indefinite"/>
              </circle>
              <circle cx={pn.x} cy={pn.y} r={28} fill="none" stroke="var(--accent)" strokeWidth={4}>
                <animate attributeName="r" from="28" to="72" dur="1.5s" begin="0.75s" repeatCount="indefinite"/>
                <animate attributeName="opacity" from="0.85" to="0" dur="1.5s" begin="0.75s" repeatCount="indefinite"/>
                <animate attributeName="stroke-width" from="4" to="0.5" dur="1.5s" begin="0.75s" repeatCount="indefinite"/>
              </circle>
            </g>
          );
        })()}
      </svg>

      {/* Zoom controls */}
      <div style={{ position: 'absolute', top: 12, right: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
        <button className="btn sm" onClick={() => zoomBtn(0.8)} title="Zoom in" style={{ width: 28, padding: 0 }}>＋</button>
        <button className="btn sm" onClick={() => zoomBtn(1.25)} title="Zoom out" style={{ width: 28, padding: 0 }}>−</button>
        <button className="btn sm" onClick={fit} title="Fit to view" style={{ width: 28, padding: 0 }}>⤢</button>
      </div>

      {/* Stats overlay */}
      <div style={{
        position: 'absolute', bottom: 12, left: 12,
        padding: '8px 12px', background: 'var(--surface)',
        border: '1px solid var(--border)', borderRadius: 8,
        display: 'flex', alignItems: 'center', gap: 14, fontSize: 11,
      }}>
        <span><strong className="mono">{layout.macroNodes.length}</strong> <span className="text-tertiary">macro</span></span>
        <span><strong className="mono">{microAll.length}</strong> <span className="text-tertiary">micro</span></span>
        <span><strong className="mono">{visibleEdges.length}</strong> <span className="text-tertiary">edges</span></span>
        <span className="text-tertiary">scroll = zoom · drag = pan</span>
      </div>
    </div>
  );
}

function TableCard({ node, columnCount, selected, dim, onClick }) {
  const W = 96, H = 44;
  const color = GRAPH_COLORS.table;
  return (
    <g data-node transform={`translate(${node.x - W/2} ${node.y - H/2})`}
       onClick={(e) => { e.stopPropagation(); onClick(node); }}
       style={{ cursor: 'pointer', opacity: dim ? 0.3 : 1 }}>
      {selected && <rect x={-5} y={-5} width={W + 10} height={H + 10} rx={12}
        fill="none" stroke={color} strokeOpacity={0.3} strokeWidth={2}/>}
      <rect width={W} height={H} rx={8} fill="var(--surface)" stroke={color} strokeWidth={1.5}/>
      <path d={`M 0 8 Q 0 0 8 0 L ${W - 8} 0 Q ${W} 0 ${W} 8 L ${W} 18 L 0 18 Z`} fill={color}/>
      <text x={W/2} y={13} fontSize={11} fontWeight={600} textAnchor="middle" fill="#FFFFFF"
        style={{ userSelect: 'none', pointerEvents: 'none' }}>
        {truncateLabel(node.name, 14)}
      </text>
      <text x={W/2} y={34} fontSize={10} textAnchor="middle" fill="var(--text-secondary)"
        style={{ userSelect: 'none', pointerEvents: 'none' }}>
        {columnCount} column{columnCount === 1 ? '' : 's'}
      </text>
    </g>
  );
}

function DocumentCard({ node, chunkCount, selected, dim, onClick }) {
  const W = 100, H = 46;
  const color = GRAPH_COLORS.doc;
  const fold = 12;
  return (
    <g data-node transform={`translate(${node.x - W/2} ${node.y - H/2})`}
       onClick={(e) => { e.stopPropagation(); onClick(node); }}
       style={{ cursor: 'pointer', opacity: dim ? 0.3 : 1 }}>
      {selected && <rect x={-5} y={-5} width={W + 10} height={H + 10} rx={12}
        fill="none" stroke={color} strokeOpacity={0.3} strokeWidth={2}/>}
      <path d={`M 0 8
                Q 0 0 8 0
                L ${W - fold} 0
                L ${W} ${fold}
                L ${W} ${H - 8}
                Q ${W} ${H} ${W - 8} ${H}
                L 8 ${H}
                Q 0 ${H} 0 ${H - 8}
                Z`}
        fill="var(--surface)" stroke={color} strokeWidth={1.5}/>
      <path d={`M ${W - fold} 0 L ${W - fold} ${fold} L ${W} ${fold} Z`} fill={color} opacity={0.2}/>
      <path d={`M ${W - fold} 0 L ${W - fold} ${fold} L ${W} ${fold}`} fill="none" stroke={color} strokeWidth={1.2}/>

      <text x={W/2} y={25} fontSize={10} fontWeight={600} textAnchor="middle" fill="var(--text-primary)"
        style={{ userSelect: 'none', pointerEvents: 'none' }}>
        {truncateLabel(node.name, 14)}
      </text>
      <text x={W/2} y={39} fontSize={9} textAnchor="middle" fill="var(--text-secondary)"
        style={{ userSelect: 'none', pointerEvents: 'none' }}>
        {chunkCount} chunk{chunkCount === 1 ? '' : 's'}
      </text>
    </g>
  );
}

function MicroDot({ node, selected, view, onClick, dim }) {
  const color = view === 'db' ? GRAPH_COLORS.table : GRAPH_COLORS.doc;
  const isChunk = node.type === 'chunk';
  const r = 5.5;
  return (
    <g data-node transform={`translate(${node.x} ${node.y})`}
       onClick={(e) => { e.stopPropagation(); onClick(node); }}
       style={{ cursor: 'pointer', opacity: dim ? 0.3 : 1 }}>
      {selected && (isChunk
        ? <rect x={-r - 3} y={-r - 3} width={(r + 3) * 2} height={(r + 3) * 2} rx={3}
                fill="none" stroke={color} strokeOpacity={0.4} strokeWidth={1.5}/>
        : <circle r={r + 3} fill="none" stroke={color} strokeOpacity={0.4} strokeWidth={1.5}/>
      )}
      {isChunk
        ? <rect x={-r} y={-r} width={r * 2} height={r * 2} rx={1.5}
                fill={selected ? color : 'var(--surface)'} stroke={color} strokeWidth={1.4}/>
        : <circle r={r} fill={selected ? color : 'var(--surface)'} stroke={color} strokeWidth={1.4}/>}
      <text y={r + 10} fontSize={8.5} textAnchor="middle"
        fill={selected ? 'var(--text-primary)' : 'var(--text-tertiary)'}
        style={{ paintOrder: 'stroke', stroke: 'var(--bg)', strokeWidth: 3, userSelect: 'none', pointerEvents: 'none' }}>
        {truncateLabel(node.name, 12)}
      </text>
    </g>
  );
}

function ConceptNode({ node, selected, chips, isBridge, view, onClick, onBridgeJump, onChipClick, dim }) {
  const r = 18;
  const chipsOut = chips && chips.length > 0 ? layoutCategoryChips(chips) : [];
  const bridgeSide = view === 'db' ? 1 : -1; // db → dashes to the right, docs → to the left
  const bridgeEndX = (r + 54) * bridgeSide;

  return (
    <g data-node transform={`translate(${node.x} ${node.y})`}
       style={{ opacity: dim ? 0.3 : 1 }}>
      {/* Bridge: outgoing dashed line + jump hit area */}
      {isBridge && (
        <g onClick={(e) => { e.stopPropagation(); onBridgeJump(node.id); }} style={{ cursor: 'pointer' }}>
          <line x1={r * bridgeSide} y1={0} x2={bridgeEndX} y2={0}
            stroke={GRAPH_COLORS.bridge} strokeWidth={1.6}
            strokeDasharray="5 3" strokeLinecap="round"/>
          <circle cx={bridgeEndX} cy={0} r={3.5} fill={GRAPH_COLORS.bridge}/>
          <text x={bridgeEndX + 6 * bridgeSide} y={3} fontSize={9}
            textAnchor={bridgeSide > 0 ? 'start' : 'end'}
            fill={GRAPH_COLORS.bridge}
            style={{ userSelect: 'none',
              paintOrder: 'stroke', stroke: 'var(--bg)', strokeWidth: 3 }}>
            {view === 'db' ? 'Docs →' : '← DB'}
          </text>
          {/* invisible wider hit area */}
          <line x1={r * bridgeSide} y1={0}
            x2={bridgeEndX + 30 * bridgeSide} y2={0}
            stroke="transparent" strokeWidth={14}/>
        </g>
      )}

      {/* Selection ring */}
      {selected && <circle r={r + 6} fill="none" stroke="var(--accent)" strokeOpacity={0.3} strokeWidth={2}/>}

      {/* Body */}
      <circle r={r} fill={selected ? 'var(--accent)' : 'var(--surface)'}
        stroke="var(--accent)" strokeWidth={1.75}
        onClick={(e) => { e.stopPropagation(); onClick(node); }}
        style={{ cursor: 'pointer' }}/>
      <text y={4} fontSize={11} textAnchor="middle"
        fill={selected ? 'var(--on-accent)' : 'var(--text-primary)'}
        style={{ userSelect: 'none', pointerEvents: 'none' }}>
        {truncateLabel(node.name, 10)}
      </text>

      {/* Category value chips below the concept body */}
      {chipsOut.length > 0 && (
        <g transform={`translate(0 ${r + 10})`}>
          {chipsOut.map((c) => (
            <g key={c.id} transform={`translate(${c.x} ${c.y})`}
              onClick={(e) => { e.stopPropagation(); onChipClick && onChipClick(c.id); }}
              style={{ cursor: 'pointer' }}>
              <rect x={0} y={0} width={c.w} height={c.h} rx={8}
                fill="var(--accent-muted)" stroke="var(--accent-text)" strokeWidth={0.8}/>
              <text x={c.w / 2} y={c.h / 2 + 3} fontSize={9} textAnchor="middle"
                fill="var(--accent-text)"
                style={{ userSelect: 'none', pointerEvents: 'none' }}>
                {truncateLabel(String(c.value || c.name || ''), 10)}
              </text>
            </g>
          ))}
        </g>
      )}
    </g>
  );
}

function layoutCategoryChips(chips, maxRowWidth = 220) {
  const gap = 5;
  const padding = 10;
  const chipH = 16;
  const rowGap = 4;

  const widths = chips.map(c => {
    const label = String(c.value || c.name || '');
    return Math.max(26, Math.min(68, label.length * 6 + padding));
  });

  const rows = [[]];
  let curRowW = 0;
  widths.forEach((w, idx) => {
    const cur = rows[rows.length - 1];
    const addW = cur.length === 0 ? w : w + gap;
    if (cur.length > 0 && curRowW + addW > maxRowWidth) {
      rows.push([]);
      curRowW = 0;
    }
    rows[rows.length - 1].push({ idx, w });
    curRowW = rows[rows.length - 1].length === 1 ? w : curRowW + gap + w;
  });

  const out = chips.map(c => ({ ...c }));
  rows.forEach((row, rowI) => {
    const totalW = row.reduce((s, r) => s + r.w, 0) + gap * Math.max(0, row.length - 1);
    let x = -totalW / 2;
    row.forEach(r => {
      out[r.idx].x = x;
      out[r.idx].y = rowI * (chipH + rowGap);
      out[r.idx].w = r.w;
      out[r.idx].h = chipH;
      x += r.w + gap;
    });
  });
  return out;
}

function truncateLabel(s, n) {
  if (!s) return '';
  return s.length > n ? s.slice(0, n - 1) + '…' : s;
}

function zoomAtCenter(vb, ratio, layout) {
  const cx = vb.x + vb.w / 2;
  const cy = vb.y + vb.h / 2;
  const newW = Math.max(120, Math.min(layout.width * 3, vb.w * ratio));
  const eff = newW / vb.w;
  const newH = vb.h * eff;
  return { x: cx - newW / 2, y: cy - newH / 2, w: newW, h: newH };
}

function GraphLoading() {
  return (
    <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', minHeight: 360 }}>
      <div style={{ textAlign: 'center' }}>
        <div className="skeleton" style={{ width: 220, height: 18, margin: '0 auto' }}/>
        <div className="mono-sm mono text-tertiary" style={{ marginTop: 10 }}>Loading graph snapshot…</div>
      </div>
    </div>
  );
}

function GraphError({ error, reload }) {
  return (
    <div style={{ padding: 48, textAlign: 'center' }}>
      <Icons.AlertTri size={28} style={{ color: 'var(--error)' }}/>
      <div className="t-h3" style={{ marginTop: 10, color: 'var(--error)' }}>Failed to load graph</div>
      <div className="text-secondary t-small" style={{ marginTop: 4 }}>{error}</div>
      <button className="btn sm" style={{ marginTop: 16 }} onClick={reload}>
        <Icons.Refresh size={12}/> Retry
      </button>
    </div>
  );
}

