// graph.jsx — Force-directed concept graph (Obsidian-style).
// Nodes = concepts, edges = links between concepts (undirected display).
// Drag nodes to reposition, drag background to pan, click node to open.

const { useState: _g_us, useEffect: _g_ue, useRef: _g_ur, useMemo: _g_um } = React;

const _G_REPULSE   = 14000;
const _G_SPRING_LEN = 190;
const _G_SPRING_K  = 0.05;
const _G_GRAVITY   = 0.006;
const _G_DAMPING   = 0.82;

function _gInit(concepts) {
  const n = concepts.length;
  return concepts.map((c, i) => ({
    id: c.id,
    x: Math.cos((i / n) * Math.PI * 2) * 170,
    y: Math.sin((i / n) * Math.PI * 2) * 170,
    vx: 0, vy: 0, pinned: false,
  }));
}

function _gEdges(concepts) {
  const seen = new Set(), result = [];
  concepts.forEach(c =>
    (c.links || []).forEach(link => {
      const tid  = typeof link === 'string' ? link : link.targetId;
      const type = typeof link === 'string' ? 'unknown' : (link.type || 'unknown');
      const key  = [c.id, tid].sort().join('\0');
      if (!seen.has(key)) { seen.add(key); result.push([c.id, tid, type]); }
    })
  );
  return result;
}

function _nodeColor(c) {
  if (c.essences?.length)     return 'var(--c-essence)';
  if (c.patterns?.length)     return 'var(--c-pattern)';
  if (c.perspectives?.length) return 'var(--c-perspective)';
  if (c.function)             return 'var(--c-function)';
  if (c.consist)              return 'var(--c-consist)';
  return 'var(--border)';
}

const _LS = {
  essence:     { col: 'var(--c-essence)',     w: 2.0, op: 0.75, dash: null },
  pattern:     { col: 'var(--c-pattern)',     w: 1.5, op: 0.65, dash: null },
  perspective: { col: 'var(--c-perspective)', w: 1.1, op: 0.55, dash: '5 4' },
};

function _edgeSign(aId, bId) {
  const s = aId < bId ? aId + bId : bId + aId;
  let h = 0;
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff;
  return h % 2 === 0 ? 1 : -1;
}

function _edgePathD(ax, ay, bx, by, offset, targetR = 0) {
  const dx = bx - ax, dy = by - ay;
  const d = Math.sqrt(dx * dx + dy * dy) || 1;

  if (!offset) {
    return `M${ax},${ay} L${bx - dx / d * targetR},${by - dy / d * targetR}`;
  }

  // Control point based on full (bx, by)
  const mx = (ax + bx) / 2, my = (ay + by) / 2;
  const scaled = (offset / 100) * Math.min(d * 0.7, 180);
  const cpx = mx - dy / d * scaled;
  const cpy = my + dx / d * scaled;

  // Endpoint offset along the curve's end tangent (cp → b)
  const tdx = bx - cpx, tdy = by - cpy;
  const td = Math.sqrt(tdx * tdx + tdy * tdy) || 1;
  const ex = bx - tdx / td * targetR;
  const ey = by - tdy / td * targetR;

  return `M${ax},${ay} Q${cpx},${cpy} ${ex},${ey}`;
}

const _ARROW_DEFS = (
  <defs>
    {[
      ['essence',     'var(--c-essence)'],
      ['pattern',     'var(--c-pattern)'],
      ['perspective', 'var(--c-perspective)'],
      ['default',     'var(--fg-3)'],
      ['hot',         'var(--c-nucleus)'],
    ].map(([id, col]) => (
      <marker key={id} id={`gm-arr-${id}`}
        viewBox="0 0 10 10" refX="9" refY="5"
        markerWidth="8" markerHeight="8"
        markerUnits="userSpaceOnUse" orient="auto">
        <path d="M0,1 L9,5 L0,9 Z" fill={col} />
      </marker>
    ))}
  </defs>
);

// Thin wrapper — holds mode + selectedId state. Graph2D/Graph3D never do conditional hooks.
function _GSwitch({ label, value, onChange }) {
  return (
    <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
      <span style={{ fontSize: 11, color: "var(--fg-3)", fontFamily: "var(--font-mono)", letterSpacing: ".04em" }}>{label}</span>
      <button
        type="button"
        onClick={() => onChange(!value)}
        role="switch"
        aria-checked={!!value}
        style={{
          position: "relative", width: 36, height: 20, border: "none", borderRadius: 999,
          background: value ? "var(--c-nucleus)" : "var(--border)",
          cursor: "pointer", transition: "background 200ms", flexShrink: 0, padding: 0,
        }}
      >
        <span style={{
          position: "absolute", top: 2, left: value ? 18 : 2,
          width: 16, height: 16, borderRadius: "50%",
          background: "#fff",
          boxShadow: "0 1px 3px rgba(0,0,0,.25)",
          transition: "left 200ms",
          display: "block",
        }} />
      </button>
    </div>
  );
}

function _GraphSlider({ label, value, min, max, step, onChange }) {
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
      <div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "var(--fg-3)", fontFamily: "var(--font-mono)", letterSpacing: ".04em" }}>
        <span>{label}</span>
        <span>{value}</span>
      </div>
      <input type="range" min={min} max={max} step={step} value={value}
        onChange={e => onChange(Number(e.target.value))}
        style={{ width: "100%", accentColor: "var(--c-nucleus)", cursor: "pointer" }}
      />
    </div>
  );
}

function Graph({ concepts, onUpdate, onBack, onOpenSolar, density, backLabel, onOpenConcept, initialSelectedId, panelW: externalPanelW, onPanelWChange }) {
  const _gs = (() => { try { return JSON.parse(localStorage.getItem('disene_graph_settings')) || {}; } catch { return {}; } })();

  const [mode, setMode]             = _g_us('2d');
  const [selectedId, setSelectedId] = _g_us(initialSelectedId || null);
  const [showSettings, setShowSettings] = _g_us(false);
  const [nodeScale, setNodeScale]   = _g_us(_gs.nodeScale   ?? 1);
  const [edgeScale, setEdgeScale]   = _g_us(_gs.edgeScale   ?? 1);
  const [springLen, setSpringLen]   = _g_us(_gs.springLen   ?? 190);
  const [curvature,    setCurvature]    = _g_us(_gs.curvature    ?? false);
  const [repulsion,    setRepulsion]    = _g_us(_gs.repulsion    ?? 28);
  const [centerForce,  setCenterForce]  = _g_us(_gs.centerForce  ?? 6);
  const [showArrows,   setShowArrows]   = _g_us(_gs.showArrows   ?? false);

  _g_ue(() => {
    localStorage.setItem('disene_graph_settings', JSON.stringify({ nodeScale, edgeScale, springLen, curvature, repulsion, centerForce, showArrows }));
  }, [nodeScale, edgeScale, springLen, curvature, repulsion, centerForce, showArrows]);
  const [internalPanelW, setInternalPanelW] = _g_us(460);
  const panelW    = externalPanelW !== undefined ? externalPanelW : internalPanelW;
  const setPanelW = externalPanelW !== undefined
    ? (w) => onPanelWChange && onPanelWChange(w)
    : setInternalPanelW;
  const resizingRef                 = _g_ur(null);

  const selected = selectedId ? concepts.find(c => c.id === selectedId) : null;
  const toggleSettings = () => setShowSettings(s => !s);
  const switchMode = (m) => { if (selectedId && onOpenConcept) onOpenConcept(selectedId, m); };

  _g_ue(() => {
    if (selectedId && window.innerWidth <= 600 && onOpenConcept) {
      onOpenConcept(selectedId, 'edit');
      setSelectedId(null);
    }
  }, [selectedId]);
  const openSolarSelected = () => { if (selectedId && onOpenSolar) onOpenSolar(selectedId, true); };

  function onResizeStart(e) {
    e.preventDefault();
    resizingRef.current = { startX: e.clientX, startW: panelW };
    const onMove = (ev) => {
      const delta = resizingRef.current.startX - ev.clientX;
      setPanelW(Math.max(280, Math.min(820, resizingRef.current.startW + delta)));
    };
    const onUp = () => {
      document.removeEventListener('pointermove', onMove);
      document.removeEventListener('pointerup', onUp);
      resizingRef.current = null;
    };
    document.addEventListener('pointermove', onMove);
    document.addEventListener('pointerup', onUp);
  }

  const graphNode = mode === '3d'
    ? <window.Graph3D concepts={concepts} onOpen={setSelectedId} onBack={onBack} onSwitch={() => setMode('2d')} selectedId={selectedId} graphNodeScale={nodeScale} graphEdgeScale={edgeScale} graphSpring={springLen} onToggleSettings={toggleSettings} backLabel={backLabel} onSwitchMode={switchMode} onOpenSolarSelected={openSolarSelected} />
    : <Graph2D        concepts={concepts} onOpen={setSelectedId} onBack={onBack} onSwitch3D={() => setMode('3d')} selectedId={selectedId} graphNodeScale={nodeScale} graphEdgeScale={edgeScale} graphSpring={springLen} onToggleSettings={toggleSettings} backLabel={backLabel} onSwitchMode={switchMode} onOpenSolarSelected={openSolarSelected} curvature={curvature ? 50 : 0} graphRepulsion={repulsion * 500} graphGravity={centerForce * 0.001} showArrows={showArrows} />;

  return (
    <div style={{ position: "relative", display: "flex", overflow: "hidden" }}>
      <div style={{ flex: 1, minWidth: 0 }}>{graphNode}</div>

      {selected && (
        <div style={{
          position: "relative",
          width: panelW, flexShrink: 0,
          height: "calc(100vh - 60px)",
          borderLeft: "1px solid var(--border-soft)",
          background: "var(--bg)",
          overflowY: "auto",
        }}>
          {/* resize handle */}
          <div
            onPointerDown={onResizeStart}
            style={{
              position: "absolute", left: 0, top: 0, bottom: 0, width: 6,
              cursor: "col-resize", zIndex: 10,
            }}
          />
          <window.ConceptEdit
            concept={selected}
            allConcepts={concepts}
            onChange={(patch) => onUpdate && onUpdate(selected.id, patch)}
            onBack={() => setSelectedId(null)}
            onSwitchMode={() => {}}
            onOpenSolar={() => onOpenSolar && onOpenSolar(selected.id)}
            onOpenConcept={setSelectedId}
            density={density || 'regular'}
            panelMode={true}
          />
        </div>
      )}

      {showSettings && (
        <div style={{
          position: "absolute", bottom: 60, right: 16, zIndex: 20,
          background: "var(--bg-elev)", border: "1px solid var(--border)",
          borderRadius: 10, padding: "14px 16px",
          boxShadow: "var(--shadow-lift)", minWidth: 220, display: "flex", flexDirection: "column", gap: 12,
        }}>
          <_GraphSlider label="Node size"    value={nodeScale}   min={0.5} max={2}   step={0.1} onChange={setNodeScale} />
          <_GraphSlider label="Edge width"   value={edgeScale}   min={0.3} max={3}   step={0.1} onChange={setEdgeScale} />
          <_GraphSlider label="Spacing"      value={springLen}   min={80}  max={400} step={10}  onChange={setSpringLen} />
          <_GSwitch label="Curved" value={!!curvature} onChange={setCurvature} />
          <_GSwitch label="Arrows" value={showArrows} onChange={setShowArrows} />
          <div style={{ height: 1, background: "var(--border-soft)", margin: "2px 0" }} />
          <_GraphSlider label="Repulsion"    value={repulsion}   min={2}   max={100} step={2}   onChange={setRepulsion} />
          <_GraphSlider label="Center force" value={centerForce} min={0}   max={20}  step={1}   onChange={setCenterForce} />
        </div>
      )}
    </div>
  );
}

function Graph2D({ concepts, onOpen, onBack, onSwitch3D, selectedId, graphNodeScale = 1, graphEdgeScale = 1, graphSpring = 190, onToggleSettings, backLabel, onSwitchMode, onOpenSolarSelected, curvature = 0, graphRepulsion = 14000, graphGravity = 0.006, showArrows = false }) {
  const nodesRef = _g_ur(null);
  if (!nodesRef.current) nodesRef.current = _gInit(concepts);

  const [, rerender] = _g_us(0);
  const [hover, setHover]   = _g_us(null);
  const [pan, setPan]       = _g_us({ x: 0, y: 0 });
  const [zoom, setZoom]     = _g_us(1);
  const [size, setSize]     = _g_us({ w: 800, h: 600 });
  const svgRef   = _g_ur(null);
  const containerRef = _g_ur(null);
  const panRef   = _g_ur({ x: 0, y: 0 });
  const zoomRef  = _g_ur(1);
  const dragNode   = _g_ur(null);
  const dragBg     = _g_ur(null);
  const dragOffset = _g_ur({ x: 0, y: 0 });
  const moved      = _g_ur(false);
  const wheelRef = _g_ur(null);
  const springRef    = _g_ur(graphSpring);    springRef.current    = graphSpring;
  const repulsionRef = _g_ur(graphRepulsion); repulsionRef.current = graphRepulsion;
  const gravityRef   = _g_ur(graphGravity);   gravityRef.current   = graphGravity;

  // Updated each render so the wheel handler always sees fresh size/pan/zoom
  wheelRef.current = (e) => {
    e.preventDefault();
    const svg = svgRef.current;
    if (!svg) return;
    const rect = svg.getBoundingClientRect();
    const mx = e.clientX - rect.left;
    const my = e.clientY - rect.top;
    const factor = Math.exp(-e.deltaY * 0.001);
    const oz = zoomRef.current;
    const nz = Math.min(Math.max(oz * factor, 0.15), 6);
    const ox = size.w / 2 + panRef.current.x;
    const oy = size.h / 2 + panRef.current.y;
    const p = { x: mx - (mx - ox) * nz / oz - size.w / 2, y: my - (my - oy) * nz / oz - size.h / 2 };
    panRef.current = p; zoomRef.current = nz;
    setPan(p); setZoom(nz);
  };

  const edges = _g_um(() => _gEdges(concepts), [concepts]);

  // Resize observer
  _g_ue(() => {
    const ro = new ResizeObserver(([e]) =>
      setSize({ w: e.contentRect.width, h: e.contentRect.height })
    );
    if (svgRef.current) ro.observe(svgRef.current);
    return () => ro.disconnect();
  }, []);

  // Wheel zoom (non-passive so we can preventDefault)
  _g_ue(() => {
    const el = containerRef.current;
    if (!el) return;
    const handler = (e) => wheelRef.current(e);
    el.addEventListener('wheel', handler, { passive: false });
    return () => el.removeEventListener('wheel', handler);
  }, []);

  // Force simulation
  _g_ue(() => {
    let alive = true, raf;
    const step = () => {
      if (!alive) return;
      const ns = nodesRef.current;
      const nm = {};
      ns.forEach(n => { nm[n.id] = n; });

      // Repulsion between all pairs
      for (let i = 0; i < ns.length; i++) {
        for (let j = i + 1; j < ns.length; j++) {
          const dx = ns[j].x - ns[i].x, dy = ns[j].y - ns[i].y;
          const d2 = Math.max(dx * dx + dy * dy, 100);
          const d  = Math.sqrt(d2);
          const f  = repulsionRef.current / d2;
          const fx = f * dx / d, fy = f * dy / d;
          ns[i].vx -= fx; ns[i].vy -= fy;
          ns[j].vx += fx; ns[j].vy += fy;
        }
      }

      // Spring attraction along edges
      for (const [aId, bId] of edges) {
        const a = nm[aId], b = nm[bId];
        if (!a || !b) continue;
        const dx = b.x - a.x, dy = b.y - a.y;
        const d  = Math.sqrt(dx * dx + dy * dy) || 1;
        const f  = (d - springRef.current) * _G_SPRING_K;
        const fx = f * dx / d, fy = f * dy / d;
        if (!a.pinned) { a.vx += fx; a.vy += fy; }
        if (!b.pinned) { b.vx -= fx; b.vy -= fy; }
      }

      // Center gravity + integrate
      for (const n of ns) {
        if (n.pinned) continue;
        n.vx += -n.x * gravityRef.current;
        n.vy += -n.y * gravityRef.current;
        n.vx *= _G_DAMPING;
        n.vy *= _G_DAMPING;
        n.x  += n.vx;
        n.y  += n.vy;
      }

      rerender(r => r + 1);
      raf = requestAnimationFrame(step);
    };
    raf = requestAnimationFrame(step);
    return () => { alive = false; cancelAnimationFrame(raf); };
  }, [edges]);

  function toGraph(clientX, clientY) {
    const svg = svgRef.current;
    if (!svg) return { x: 0, y: 0 };
    const rect = svg.getBoundingClientRect();
    return {
      x: (clientX - rect.left  - size.w / 2 - panRef.current.x) / zoomRef.current,
      y: (clientY - rect.top   - size.h / 2 - panRef.current.y) / zoomRef.current,
    };
  }

  function onSvgDown(e) {
    moved.current = false;
    dragBg.current = { sx: e.clientX, sy: e.clientY, sp: { ...panRef.current } };
  }

  function onNodeDown(e, id) {
    e.stopPropagation();
    moved.current = false;
    const n = nodesRef.current.find(n => n.id === id);
    if (n) {
      n.pinned = true;
      const cur = toGraph(e.clientX, e.clientY);
      dragOffset.current = { x: cur.x - n.x, y: cur.y - n.y };
    }
    dragNode.current = id;
    dragBg.current = null;
    e.currentTarget.setPointerCapture?.(e.pointerId);
  }

  function onMove(e) {
    moved.current = true;
    if (dragNode.current) {
      const { x, y } = toGraph(e.clientX, e.clientY);
      const n = nodesRef.current.find(n => n.id === dragNode.current);
      if (n) { n.x = x - dragOffset.current.x; n.y = y - dragOffset.current.y; n.vx = 0; n.vy = 0; }
    } else if (dragBg.current) {
      const p = {
        x: dragBg.current.sp.x + e.clientX - dragBg.current.sx,
        y: dragBg.current.sp.y + e.clientY - dragBg.current.sy,
      };
      panRef.current = p;
      setPan(p);
    }
  }

  function onUp() {
    if (dragNode.current) {
      const n = nodesRef.current.find(n => n.id === dragNode.current);
      if (n) { n.pinned = false; n.vx = 0; n.vy = 0; }
      dragNode.current = null;
    }
    dragBg.current = null;
  }

  // Snapshot positions for render
  const posMap = {};
  nodesRef.current.forEach(n => { posMap[n.id] = { x: n.x, y: n.y }; });

  const nodeRadiusMap = {};
  concepts.forEach(c => { nodeRadiusMap[c.id] = (14 + window.fillCount(c) * 2.4) * graphNodeScale; });

  // Hovered edge/node sets
  const hotEdges = new Set();
  const linkedSet = new Set();
  if (hover) {
    edges.forEach(([a, b]) => {
      if (a === hover || b === hover) {
        hotEdges.add(`${a}\0${b}`);
        linkedSet.add(a === hover ? b : a);
      }
    });
  }

  const ox = size.w / 2 + pan.x;
  const oy = size.h / 2 + pan.y;
  const hoverConcept = hover ? concepts.find(c => c.id === hover) : null;

  return (
    <div
      ref={containerRef}
      style={{ position: "relative", height: "calc(100vh - 60px)", overflow: "hidden", background: "var(--bg-sunk)" }}
      onPointerMove={onMove}
      onPointerUp={onUp}
    >
      <svg
        ref={svgRef}
        style={{ width: "100%", height: "100%", display: "block" }}
        onPointerDown={onSvgDown}
      >
        {_ARROW_DEFS}
        <g transform={`translate(${ox},${oy}) scale(${zoom})`}>
          {/* Edges */}
          {edges.map(([aId, bId, type]) => {
            const a = posMap[aId], b = posMap[bId];
            if (!a || !b) return null;
            const hot = hotEdges.has(`${aId}\0${bId}`) || hotEdges.has(`${bId}\0${aId}`);
            const ls = _LS[type] || { col: 'var(--fg-3)', w: 1.0, op: 0.55 };
            const offset = curvature ? curvature * _edgeSign(aId, bId) : 0;
            const targetR = showArrows ? (nodeRadiusMap[bId] ?? 14) : 0;
            const markerId = hot ? 'hot' : (type in _LS ? type : 'default');
            return (
              <path key={`${aId}_${bId}`}
                d={_edgePathD(a.x, a.y, b.x, b.y, offset, targetR)}
                fill="none"
                stroke={hot ? "var(--c-nucleus)" : ls.col}
                strokeOpacity={hot ? 0.9 : ls.op}
                strokeWidth={(hot ? 2 : ls.w) * graphEdgeScale}
                strokeDasharray={!hot && ls.dash ? ls.dash : null}
                markerEnd={showArrows ? `url(#gm-arr-${markerId})` : undefined}
              />
            );
          })}

          {/* Nodes */}
          {concepts.map(c => {
            const p = posMap[c.id];
            if (!p) return null;
            const r        = (14 + window.fillCount(c) * 2.4) * graphNodeScale;
            const isHov    = hover === c.id;
            const isSel    = selectedId === c.id;
            const isLinked = linkedSet.has(c.id);
            const dim      = hover && !isHov && !isLinked && !isSel ? 0.22 : 1;
            const col      = _nodeColor(c);
            return (
              <g key={c.id}
                opacity={dim}
                style={{ cursor: "pointer" }}
                onPointerDown={e => onNodeDown(e, c.id)}
                onPointerEnter={() => setHover(c.id)}
                onPointerLeave={() => setHover(null)}
                onClick={() => { if (!moved.current) onOpen(c.id); }}
              >
                <circle cx={p.x} cy={p.y} r={r + 10} fill="transparent" />

                <circle
                  cx={p.x} cy={p.y} r={r}
                  fill={isHov || isSel ? col : "var(--bg-elev)"}
                  stroke={col}
                  strokeWidth={(isSel && !isHov ? 5 : isLinked && !isHov ? 4 : 3.5) * graphEdgeScale}
                  strokeOpacity={isHov || isSel ? 1 : 0.75}
                />
                <text
                  x={p.x} y={p.y + r + 16}
                  textAnchor="middle"
                  fontFamily="var(--font-serif)"
                  fontStyle="italic"
                  fontSize={isHov || isSel ? 14 : 12.5}
                  fill={isHov || isSel ? "var(--fg)" : "var(--fg-2)"}
                >{c.title}</text>
              </g>
            );
          })}
        </g>
      </svg>

      {/* Toolbar */}
      <div className="view-toolbar" style={{ position: "absolute", top: 14, left: 14, right: 14, display: "flex", alignItems: "center", gap: 12, pointerEvents: "none" }}>
        <button className="btn ghost tiny" style={{ pointerEvents: "auto" }} onClick={onBack}>
          {window.Icons.ArrowL(13)} {backLabel || "Library"}
        </button>
        <div className="tag-flat">
          {concepts.length} concept{concepts.length !== 1 ? "s" : ""} · {edges.length} link{edges.length !== 1 ? "s" : ""}
        </div>
        <div style={{ flex: 1 }} />
        {selectedId && onSwitchMode && (
          <div style={{ display: "flex", gap: 4, background: "var(--bg-sunk)", padding: 3, borderRadius: 9, border: "1px solid var(--border-soft)", pointerEvents: "auto" }}>
            <button className="btn tiny" style={{ border: 0, background: "transparent", color: "var(--fg-3)" }} onClick={() => onSwitchMode("edit")}>{window.Icons.Edit(13)} Edit</button>
            <button className="btn tiny" style={{ border: 0, background: "transparent", color: "var(--fg-3)" }} onClick={() => onSwitchMode("read")}>{window.Icons.Eye(13)} Read</button>
            <button className="btn tiny" style={{ border: 0, background: "transparent", color: "var(--fg-3)" }} onClick={onOpenSolarSelected}>{window.Icons.Planet(13)} System</button>
            <button className="btn tiny" style={{ border: 0, background: "var(--bg-elev)", boxShadow: "0 1px 2px rgba(0,0,0,.05)", color: "var(--fg)" }}>{window.Icons.Graph(13)} Graph</button>
          </div>
        )}
      </div>

      {/* Bottom-left legend */}
      <div style={{ position: "absolute", bottom: 16, left: 16, pointerEvents: "none" }}>
        <div className="surface" style={{ pointerEvents: "auto", padding: "8px 12px", display: "flex", flexDirection: "column", gap: 4 }}>
          <div style={{ display: "flex", gap: 14, fontSize: 11.5, color: "var(--fg-2)" }}>
            {[
              { label: 'Essence',     col: 'var(--c-essence)',     w: 2.5, op: 0.8,  dash: null },
              { label: 'Pattern',     col: 'var(--c-pattern)',     w: 1.8, op: 0.7,  dash: null },
              { label: 'Perspective', col: 'var(--c-perspective)', w: 1.2, op: 0.6,  dash: '5 4' },
            ].map(({ label, col, w, op, dash }) => (
              <span key={label} style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
                <svg width={20} height={8} style={{ flexShrink: 0 }}>
                  <line x1={0} y1={4} x2={20} y2={4}
                    stroke={col} strokeWidth={w} strokeOpacity={op}
                    strokeDasharray={dash || undefined}
                  />
                </svg>
                {label}
              </span>
            ))}
          </div>
        </div>
      </div>

      {/* Bottom bar — settings */}
      <div style={{ position: "absolute", bottom: 16, right: 16, pointerEvents: "none", display: "flex", gap: 8, alignItems: "center" }}>
        <button className="btn icon" style={{ pointerEvents: "auto", borderRadius: 4, padding: 7, width: 32, height: 32, justifyContent: "center" }} onClick={onToggleSettings} title="Graph settings">{window.Icons.Sliders(16)}</button>
      </div>

      {/* Hover card */}
      {hoverConcept && (
        <div style={{
          position: "absolute", bottom: 24, left: "50%", transform: "translateX(-50%)",
          background: "var(--bg-elev)", border: "1px solid var(--border)",
          borderRadius: 10, padding: "12px 16px", pointerEvents: "none",
          boxShadow: "var(--shadow-lift)", minWidth: 220, maxWidth: 380,
        }}>
          <div className="tag-flat" style={{ marginBottom: 4 }}>{hoverConcept.category}</div>
          <div className="concept-title" style={{ fontSize: 22 }}>{hoverConcept.title}</div>
          {hoverConcept.essences?.[0]?.body && (
            <div style={{ fontFamily: "var(--font-serif)", color: "var(--fg-2)", fontSize: 16, marginTop: 6, lineHeight: 1.5 }}>
              {hoverConcept.essences[0].body}
            </div>
          )}
          <div style={{ marginTop: 8, fontSize: 11, color: "var(--fg-3)", fontFamily: "var(--font-mono)", letterSpacing: ".06em", textTransform: "uppercase" }}>
            {linkedSet.size} link{linkedSet.size !== 1 ? "s" : ""} · {window.fillCount(hoverConcept)}/5 complete
          </div>
        </div>
      )}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────
// MiniGraph — small radial view of one concept's direct connections.
// Used in concept-edit right rail and concept-read bottom panel.

function MiniGraph({ concept, allConcepts, onOpen }) {
  const [hov, setHov] = _g_us(null);

  const outIds = new Set(concept.links || []);

  const connected = _g_um(() => {
    const list = [];
    const seen = new Set();
    // outgoing
    (concept.links || []).forEach(link => {
      const tid  = typeof link === 'string' ? link : link.targetId;
      const type = typeof link === 'string' ? 'unknown' : (link.type || 'unknown');
      const c = allConcepts.find(x => x.id === tid);
      if (c) { list.push({ c, dir: "out", type }); seen.add(tid); }
    });
    // incoming-only
    allConcepts.forEach(c => {
      if (c.id === concept.id || seen.has(c.id)) return;
      const inLink = (c.links || []).find(l => {
        const tid = typeof l === 'string' ? l : l.targetId;
        return tid === concept.id;
      });
      if (inLink) {
        const type = typeof inLink === 'string' ? 'unknown' : (inLink.type || 'unknown');
        list.push({ c, dir: "in", type });
      }
    });
    return list;
  }, [concept, allConcepts]);

  if (connected.length === 0) return (
    <div className="surface" style={{ padding: "14px 16px" }}>
      <div className="tag-flat" style={{ marginBottom: 8 }}>Connections</div>
      <div style={{ fontSize: 12.5, color: "var(--fg-3)", fontStyle: "italic", lineHeight: 1.5 }}>
        No linked concepts yet. Add links in Edit mode.
      </div>
    </div>
  );

  const svgRef = _g_ur(null);
  const [zoom, setZoom] = _g_us(1);
  _g_ue(() => {
    const el = svgRef.current;
    if (!el) return;
    const handler = (e) => {
      e.preventDefault();
      setZoom(z => Math.max(0.3, Math.min(5, z * Math.exp(-e.deltaY * 0.001))));
    };
    el.addEventListener("wheel", handler, { passive: false });
    return () => el.removeEventListener("wheel", handler);
  }, []);

  const W = 220, H = 200;
  const cx = W / 2, cy = H / 2;
  const R = connected.length <= 4 ? 68 : connected.length <= 7 ? 74 : 80;

  const nodes = connected.map(({ c, dir, type }, i) => {
    const angle = (i / connected.length) * Math.PI * 2 - Math.PI / 2;
    return { c, dir, type, x: cx + Math.cos(angle) * R, y: cy + Math.sin(angle) * R };
  });

  const outCount = connected.filter(n => n.dir === "out").length;
  const inCount  = connected.filter(n => n.dir === "in").length;

  const vbSize = W / zoom;
  const vbOff  = (W - vbSize) / 2;

  return (
    <div className="surface" style={{ padding: 12, display: "flex", flexDirection: "column", gap: 8 }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
        <div className="tag-flat">Connections</div>
        <span style={{ fontSize: 11, color: "var(--fg-3)", fontFamily: "var(--font-mono)" }}>
          {connected.length}
        </span>
      </div>

      <svg ref={svgRef} viewBox={`${vbOff} ${(H - vbSize) / 2} ${vbSize} ${vbSize}`} style={{ width: "100%", height: H }}>
        {/* Edges */}
        {nodes.map(n => {
          const hot = hov === n.c.id;
          const ls = _LS[n.type] || { col: 'var(--fg-3)', w: 1.0, op: 0.6 };
          return (
            <line key={n.c.id}
              x1={cx} y1={cy} x2={n.x} y2={n.y}
              stroke={hot ? "var(--c-nucleus)" : ls.col}
              strokeOpacity={hot ? 0.9 : ls.op}
              strokeWidth={hot ? 1.5 : ls.w}
              strokeDasharray={!hot ? (ls.dash || (n.dir === "in" ? "2 3" : null)) : null}
            />
          );
        })}

        {/* Center — current concept */}
        <circle cx={cx} cy={cy} r={11}
          fill="var(--bg-elev)"
          stroke={_nodeColor(concept)}
          strokeWidth={3.5}
          strokeOpacity={0.75}
        />

        {/* Connected nodes */}
        {nodes.map(n => {
          const r   = 5 + window.fillCount(n.c) * 0.9;
          const hot = hov === n.c.id;
          const col = _nodeColor(n.c);
          return (
            <g key={n.c.id}
              style={{ cursor: "pointer" }}
              onPointerEnter={() => setHov(n.c.id)}
              onPointerLeave={() => setHov(null)}
              onClick={() => onOpen(n.c.id)}
            >
              <circle cx={n.x} cy={n.y} r={r + 6} fill="transparent" />
              <circle cx={n.x} cy={n.y} r={r}
                fill={hot ? col : "var(--bg-elev)"}
                stroke={col}
                strokeWidth={2.5}
                strokeOpacity={hot ? 1 : 0.75}
              />
              <text
                x={n.x}
                y={n.y + r + 11}
                textAnchor="middle"
                fontFamily="var(--font-serif)"
                fontStyle="italic"
                fontSize={9.5}
                fill={hot ? "var(--fg)" : "var(--fg-3)"}
              >
                {n.c.title.length > 11 ? n.c.title.slice(0, 10) + "…" : n.c.title}
              </text>
            </g>
          );
        })}
      </svg>

      <div style={{ fontSize: 11, color: "var(--fg-3)", fontFamily: "var(--font-mono)", letterSpacing: ".05em" }}>
        {outCount > 0 && `${outCount} out`}
        {outCount > 0 && inCount > 0 && " · "}
        {inCount > 0 && `${inCount} in`}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────
// GraphPanel — embedded force-directed graph for the library sidebar.
function GraphPanel({ concepts, onOpen, onOpenGraph }) {
  const nodesRef = _g_ur(null);
  if (!nodesRef.current) {
    nodesRef.current = _gInit(concepts).map(n => ({ ...n, x: n.x * 0.6, y: n.y * 0.6 }));
  }

  const [, rerender] = _g_us(0);
  const [hover, setHover] = _g_us(null);
  const [pan, setPan]     = _g_us({ x: 0, y: 0 });
  const [zoom, setZoom]   = _g_us(1);
  const [size, setSize]   = _g_us({ w: 360, h: 500 });
  const svgRef      = _g_ur(null);
  const containerRef = _g_ur(null);
  const panRef      = _g_ur({ x: 0, y: 0 });
  const zoomRef     = _g_ur(1);
  const dragNode    = _g_ur(null);
  const dragBg      = _g_ur(null);
  const dragOffset  = _g_ur({ x: 0, y: 0 });
  const moved       = _g_ur(false);
  const wheelRef    = _g_ur(null);

  wheelRef.current = (e) => {
    e.preventDefault();
    const svg = svgRef.current;
    if (!svg) return;
    const rect = svg.getBoundingClientRect();
    const mx = e.clientX - rect.left;
    const my = e.clientY - rect.top;
    const factor = Math.exp(-e.deltaY * 0.001);
    const oz = zoomRef.current;
    const nz = Math.min(Math.max(oz * factor, 0.15), 6);
    const ox = size.w / 2 + panRef.current.x;
    const oy = size.h / 2 + panRef.current.y;
    const p = { x: mx - (mx - ox) * nz / oz - size.w / 2, y: my - (my - oy) * nz / oz - size.h / 2 };
    panRef.current = p; zoomRef.current = nz;
    setPan(p); setZoom(nz);
  };

  const edges = _g_um(() => _gEdges(concepts), [concepts]);

  _g_ue(() => {
    const ro = new ResizeObserver(([e]) =>
      setSize({ w: e.contentRect.width, h: e.contentRect.height })
    );
    if (svgRef.current) ro.observe(svgRef.current);
    return () => ro.disconnect();
  }, []);

  _g_ue(() => {
    const el = containerRef.current;
    if (!el) return;
    const handler = (e) => wheelRef.current(e);
    el.addEventListener('wheel', handler, { passive: false });
    return () => el.removeEventListener('wheel', handler);
  }, []);

  _g_ue(() => {
    let alive = true, raf;
    const step = () => {
      if (!alive) return;
      const ns = nodesRef.current;
      const nm = {};
      ns.forEach(n => { nm[n.id] = n; });

      for (let i = 0; i < ns.length; i++) {
        for (let j = i + 1; j < ns.length; j++) {
          const dx = ns[j].x - ns[i].x, dy = ns[j].y - ns[i].y;
          const d2 = Math.max(dx * dx + dy * dy, 100);
          const d  = Math.sqrt(d2);
          const f  = _G_REPULSE / d2;
          const fx = f * dx / d, fy = f * dy / d;
          ns[i].vx -= fx; ns[i].vy -= fy;
          ns[j].vx += fx; ns[j].vy += fy;
        }
      }

      for (const [aId, bId] of edges) {
        const a = nm[aId], b = nm[bId];
        if (!a || !b) continue;
        const dx = b.x - a.x, dy = b.y - a.y;
        const d  = Math.sqrt(dx * dx + dy * dy) || 1;
        const f  = (d - 110) * _G_SPRING_K;
        const fx = f * dx / d, fy = f * dy / d;
        if (!a.pinned) { a.vx += fx; a.vy += fy; }
        if (!b.pinned) { b.vx -= fx; b.vy -= fy; }
      }

      for (const n of ns) {
        if (n.pinned) continue;
        n.vx += -n.x * _G_GRAVITY;
        n.vy += -n.y * _G_GRAVITY;
        n.vx *= _G_DAMPING;
        n.vy *= _G_DAMPING;
        n.x  += n.vx;
        n.y  += n.vy;
      }

      rerender(r => r + 1);
      raf = requestAnimationFrame(step);
    };
    raf = requestAnimationFrame(step);
    return () => { alive = false; cancelAnimationFrame(raf); };
  }, [edges]);

  function toGraph(clientX, clientY) {
    const svg = svgRef.current;
    if (!svg) return { x: 0, y: 0 };
    const rect = svg.getBoundingClientRect();
    return {
      x: (clientX - rect.left - size.w / 2 - panRef.current.x) / zoomRef.current,
      y: (clientY - rect.top  - size.h / 2 - panRef.current.y) / zoomRef.current,
    };
  }

  function onSvgDown(e) {
    moved.current = false;
    dragBg.current = { sx: e.clientX, sy: e.clientY, sp: { ...panRef.current } };
  }

  function onNodeDown(e, id) {
    e.stopPropagation();
    moved.current = false;
    const n = nodesRef.current.find(n => n.id === id);
    if (n) {
      n.pinned = true;
      const cur = toGraph(e.clientX, e.clientY);
      dragOffset.current = { x: cur.x - n.x, y: cur.y - n.y };
    }
    dragNode.current = id;
    dragBg.current = null;
    e.currentTarget.setPointerCapture?.(e.pointerId);
  }

  function onMove(e) {
    moved.current = true;
    if (dragNode.current) {
      const { x, y } = toGraph(e.clientX, e.clientY);
      const n = nodesRef.current.find(n => n.id === dragNode.current);
      if (n) { n.x = x - dragOffset.current.x; n.y = y - dragOffset.current.y; n.vx = 0; n.vy = 0; }
    } else if (dragBg.current) {
      const p = {
        x: dragBg.current.sp.x + e.clientX - dragBg.current.sx,
        y: dragBg.current.sp.y + e.clientY - dragBg.current.sy,
      };
      panRef.current = p;
      setPan(p);
    }
  }

  function onUp() {
    if (dragNode.current) {
      const n = nodesRef.current.find(n => n.id === dragNode.current);
      if (n) { n.pinned = false; n.vx = 0; n.vy = 0; }
      dragNode.current = null;
    }
    dragBg.current = null;
  }

  const posMap = {};
  nodesRef.current.forEach(n => { posMap[n.id] = { x: n.x, y: n.y }; });

  const hotEdges = new Set(), linkedSet = new Set();
  if (hover) {
    edges.forEach(([a, b]) => {
      if (a === hover || b === hover) {
        hotEdges.add(`${a}\0${b}`);
        linkedSet.add(a === hover ? b : a);
      }
    });
  }

  const ox = size.w / 2 + pan.x;
  const oy = size.h / 2 + pan.y;
  const hoverConcept = hover ? concepts.find(c => c.id === hover) : null;

  return (
    <div
      ref={containerRef}
      style={{ position: "relative", width: "100%", height: "100%", overflow: "hidden" }}
      onPointerMove={onMove}
      onPointerUp={onUp}
    >
      <svg
        ref={svgRef}
        style={{ width: "100%", height: "100%", display: "block" }}
        onPointerDown={onSvgDown}
      >
        <g transform={`translate(${ox},${oy}) scale(${zoom})`}>
          {edges.map(([aId, bId, type]) => {
            const a = posMap[aId], b = posMap[bId];
            if (!a || !b) return null;
            const hot = hotEdges.has(`${aId}\0${bId}`) || hotEdges.has(`${bId}\0${aId}`);
            const ls = _LS[type] || { col: 'var(--fg-3)', w: 1.0, op: 0.5 };
            return (
              <line key={`${aId}_${bId}`}
                x1={a.x} y1={a.y} x2={b.x} y2={b.y}
                stroke={hot ? "var(--c-nucleus)" : ls.col}
                strokeOpacity={hot ? 0.9 : ls.op}
                strokeWidth={hot ? 1.5 : ls.w}
                strokeDasharray={!hot && ls.dash ? ls.dash : null}
              />
            );
          })}

          {concepts.map(c => {
            const p = posMap[c.id];
            if (!p) return null;
            const r        = 10 + window.fillCount(c) * 1.6;
            const isHov    = hover === c.id;
            const isLinked = linkedSet.has(c.id);
            const dim      = hover && !isHov && !isLinked ? 0.25 : 1;
            const col      = _nodeColor(c);
            return (
              <g key={c.id}
                opacity={dim}
                style={{ cursor: "pointer" }}
                onPointerDown={e => onNodeDown(e, c.id)}
                onPointerEnter={() => setHover(c.id)}
                onPointerLeave={() => setHover(null)}
                onClick={() => { if (!moved.current) onOpen(c.id); }}
              >
                <circle cx={p.x} cy={p.y} r={r + 10} fill="transparent" />
                <circle
                  cx={p.x} cy={p.y} r={r}
                  fill={isHov ? col : "var(--bg-elev)"}
                  stroke={col}
                  strokeWidth={isHov ? 3 : 2.5}
                  strokeOpacity={isHov ? 1 : 0.75}
                />
                <text
                  x={p.x} y={p.y + r + 13}
                  textAnchor="middle"
                  fontFamily="var(--font-serif)"
                  fontStyle="italic"
                  fontSize={isHov ? 11.5 : 10}
                  fill={isHov ? "var(--fg)" : "var(--fg-2)"}
                >{c.title}</text>
              </g>
            );
          })}
        </g>
      </svg>

      <div style={{
        position: "absolute", top: 12, left: 12, right: 12,
        display: "flex", alignItems: "center", gap: 8, pointerEvents: "none"
      }}>
        <span style={{ fontSize: 10.5, color: "var(--fg-3)", fontFamily: "var(--font-mono)", letterSpacing: ".05em" }}>
          {concepts.length} nodes · {edges.length} edges
        </span>
        <div style={{ flex: 1 }} />
        <button className="btn ghost tiny" style={{ pointerEvents: "auto" }} onClick={onOpenGraph}>
          expand →
        </button>
      </div>

      {hoverConcept && (
        <div style={{
          position: "absolute", bottom: 12, left: 12, right: 12,
          background: "var(--bg-elev)", border: "1px solid var(--border)",
          borderRadius: 8, padding: "10px 14px", pointerEvents: "none",
          boxShadow: "var(--shadow-lift)",
        }}>
          <div className="concept-title" style={{ fontSize: 20 }}>{hoverConcept.title}</div>
          {hoverConcept.essences?.[0]?.body && (
            <div style={{ fontFamily: "var(--font-serif)", fontStyle: "italic", color: "var(--fg-2)", fontSize: 12, marginTop: 4, lineHeight: 1.4 }}>
              &ldquo;{hoverConcept.essences[0].body}&rdquo;
            </div>
          )}
          <div style={{ marginTop: 6, fontSize: 10.5, color: "var(--fg-3)", fontFamily: "var(--font-mono)", letterSpacing: ".05em" }}>
            {linkedSet.size} link{linkedSet.size !== 1 ? "s" : ""} · {window.fillCount(hoverConcept)}/5 complete
          </div>
        </div>
      )}
    </div>
  );
}

Object.assign(window, { Graph, MiniGraph, GraphPanel });
