// Auto-layout JSON-as-graph. Each object/array becomes one node containing its
// scalar key-value pairs; nested objects/arrays become separate connected nodes.
// Direction: LR (default) or TB. Related-file mode: SET→GROUP→RULE clusters.

const NODE_MIN_W    = 160;
const NODE_MAX_W    = 500;
const COL_GAP_LR    = 80;
const ROW_GAP_LR    = 24;
const ROW_GAP_TB    = 80;
const COL_GAP_TB    = 40;
const HEADER_H      = 36;
const ROW_H         = 22;
const FILE_GAP      = 160;
const CLUSTER_GAP   = 40;

// Canvas text measurement
let _mctx = null;
function textW(text, font) {
  if (!_mctx) _mctx = document.createElement('canvas').getContext('2d');
  _mctx.font = font;
  return _mctx.measureText(String(text ?? '')).width;
}
const F_MONO_11  = '11px monospace';
const F_MONO_13B = '600 13px monospace';

function calcNodeW(key, props) {
  let w = textW(key, F_MONO_13B) + 80;
  if (props.length > 0) {
    const maxKW = Math.max(...props.map(p => textW(p.k, F_MONO_11)));
    const maxVW = Math.max(...props.map(p => textW(p.v === null ? 'null' : p.v, F_MONO_11)));
    w = Math.max(w, maxKW + maxVW + 8 + 24);
  }
  return Math.min(NODE_MAX_W, Math.max(NODE_MIN_W, Math.ceil(w)));
}

// Types that can have related children
const RELATED_TYPES = new Set(['SET', 'GROUP']);

// Cluster colors for backgrounds
const CLUSTER_COLORS = {
  '__main__':  { fill: 'rgba(46,139,107,0.06)',  stroke: 'rgba(46,139,107,0.18)' },
  '__set__':   { fill: 'rgba(217,119,6,0.06)',   stroke: 'rgba(217,119,6,0.18)' },
  '__group__': { fill: 'rgba(59,130,246,0.05)',  stroke: 'rgba(59,130,246,0.16)' },
  '__rule__':  { fill: 'rgba(168,85,247,0.05)',  stroke: 'rgba(168,85,247,0.16)' },
};

// Detect expandable reference type from an ID string
function refTypeOf(id) {
  if (!id) return null;
  const u = String(id).toUpperCase();
  if (u.startsWith('PLGP')) return 'GROUP';
  if (u.startsWith('PLRL')) return 'RULE';
  if (u.startsWith('PLSE')) return 'SET';
  return null;
}

function GraphView({ json, loading, direction = "LR", onDirectionChange,
                     fileType, relatedFiles, loadingNodes, onLoadNode, onHideNode }) {
  const [zoom, setZoom]               = React.useState(1);
  const [pan, setPan]                 = React.useState({ x: 40, y: 40 });
  const [selected, setSelected]       = React.useState(null);
  const [isFullscreen, setIsFullscreen] = React.useState(false);
  const [graphSearch, setGraphSearch] = React.useState('');
  const dragRef          = React.useRef(null);
  const canvasRef        = React.useRef(null);
  const wrapRef          = React.useRef(null);
  const zoomRef          = React.useRef(zoom);
  const panRef           = React.useRef(pan);
  const prevRelKeysRef   = React.useRef(new Set());
  const focusFileIdRef   = React.useRef(null);
  React.useEffect(() => { zoomRef.current = zoom; }, [zoom]);
  React.useEffect(() => { panRef.current  = pan;  }, [pan]);

  // Track newly added related file IDs so we can pan to them after layout
  React.useEffect(() => {
    if (!relatedFiles) { prevRelKeysRef.current = new Set(); return; }
    const cur = new Set(Object.keys(relatedFiles));
    const added = [...cur].filter(k => !prevRelKeysRef.current.has(k));
    if (added.length > 0) focusFileIdRef.current = added[0];
    prevRelKeysRef.current = cur;
  }, [relatedFiles]);

  // Fullscreen
  React.useEffect(() => {
    const onChange = () => setIsFullscreen(!!document.fullscreenElement);
    document.addEventListener('fullscreenchange', onChange);
    return () => document.removeEventListener('fullscreenchange', onChange);
  }, []);
  function toggleFullscreen() {
    if (!document.fullscreenElement) wrapRef.current?.requestFullscreen();
    else document.exitFullscreen();
  }

  // Layout
  const hasRelated = relatedFiles && Object.keys(relatedFiles).length > 0;
  const layout = React.useMemo(() => {
    if (!json) return null;
    if (hasRelated) return layoutRelatedGraph(json, fileType, relatedFiles, direction);
    return layoutJsonGraph(json, direction);
  }, [json, direction, relatedFiles, fileType]);

  // Center pan when layout changes
  function centeredPan(lyt) {
    const root = lyt.nodes[0];
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return { x: 40, y: 40 };
    if (direction === "LR") return { x: 40, y: Math.max(16, (rect.height - root.h) / 2) };
    return { x: Math.max(16, (rect.width - root.w) / 2), y: 40 };
  }
  React.useEffect(() => {
    if (!layout) return;
    const focusId = focusFileIdRef.current;
    focusFileIdRef.current = null;
    if (focusId) {
      const targets = layout.nodes.filter(n => n.fileId === focusId);
      if (targets.length > 0) {
        const rect = canvasRef.current?.getBoundingClientRect();
        if (rect) {
          const cx = targets.reduce((s, n) => s + n.x + n.w / 2, 0) / targets.length;
          const cy = targets.reduce((s, n) => s + n.y + n.h / 2, 0) / targets.length;
          const z  = zoomRef.current;
          setPan({ x: rect.width / 2 - cx * z, y: rect.height / 2 - cy * z });
          return;
        }
      }
    }
    setPan(centeredPan(layout));
    setZoom(1);
  }, [layout]);

  // Scroll-to-zoom (non-passive)
  React.useEffect(() => {
    const el = canvasRef.current;
    if (!el) return;
    const onWheel = (e) => {
      e.preventDefault();
      const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
      const rect = el.getBoundingClientRect();
      const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
      const pz = zoomRef.current, pp = panRef.current;
      const nz = Math.max(0.2, Math.min(3, pz * factor));
      setZoom(nz);
      setPan({ x: cx - (cx - pp.x) * (nz / pz), y: cy - (cy - pp.y) * (nz / pz) });
    };
    el.addEventListener('wheel', onWheel, { passive: false });
    return () => el.removeEventListener('wheel', onWheel);
  }, []);

  // Search match set
  const matchSet = React.useMemo(() => {
    if (!graphSearch.trim() || !layout) return new Set();
    const q = graphSearch.trim().toLowerCase();
    const s = new Set();
    layout.nodes.forEach((n, i) => {
      if (n.key.toLowerCase().includes(q)) { s.add(i); return; }
      for (const p of n.props) {
        if (String(p.k).toLowerCase().includes(q) || String(p.v ?? '').toLowerCase().includes(q)) {
          s.add(i); return;
        }
      }
    });
    return s;
  }, [graphSearch, layout]);

  if (loading) {
    return <div className="pv-loading"><div className="pv-spinner" /><span>Loading graph…</span></div>;
  }
  if (!layout) {
    return (
      <div className="pv-empty" data-screen-label="Graph empty">
        <div>No policy loaded.</div>
        <div className="hint">Open a file to see its structure as a graph.</div>
      </div>
    );
  }

  function onMouseDown(e) {
    if (e.target.closest(".pv-jg-node")) return;
    dragRef.current = { x: e.clientX, y: e.clientY, ox: pan.x, oy: pan.y };
  }
  function onMouseMove(e) {
    if (!dragRef.current) return;
    setPan({ x: dragRef.current.ox + (e.clientX - dragRef.current.x), y: dragRef.current.oy + (e.clientY - dragRef.current.y) });
  }
  function onMouseUp() { dragRef.current = null; }
  function fitView() { const p = centeredPan(layout); setZoom(1); setPan(p); }

  const matchCount = matchSet.size;

  // Cluster backgrounds (only in related mode)
  const clusters = [];
  if (hasRelated && layout.nodes.some(n => n.fileId)) {
    const bounds = {};
    layout.nodes.forEach((n) => {
      const fid = n.fileId || '__main__';
      const rt = refTypeOf(fid);
      const colorKey = fid === '__main__' ? '__main__' : rt === 'GROUP' ? '__group__' : rt === 'RULE' ? '__rule__' : rt === 'SET' ? '__set__' : '__main__';
      if (!bounds[fid]) bounds[fid] = { x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity, colorKey };
      bounds[fid].x1 = Math.min(bounds[fid].x1, n.x);
      bounds[fid].y1 = Math.min(bounds[fid].y1, n.y);
      bounds[fid].x2 = Math.max(bounds[fid].x2, n.x + n.w);
      bounds[fid].y2 = Math.max(bounds[fid].y2, n.y + n.h);
    });
    for (const [fid, b] of Object.entries(bounds)) {
      const PAD = 14;
      clusters.push({ fid, x: b.x1 - PAD, y: b.y1 - PAD, w: b.x2 - b.x1 + PAD * 2, h: b.y2 - b.y1 + PAD * 2, ...CLUSTER_COLORS[b.colorKey] });
    }
  }

  return (
    <div ref={wrapRef} className="pv-jg-wrap" data-screen-label="Graph view">
      <div className="pv-jg-toolbar">
        <div className="pv-jg-segmented" role="tablist" aria-label="Layout direction">
          <button role="tab" aria-selected={direction === "LR"}
            className={cx("pv-jg-seg-opt", direction === "LR" && "is-active")}
            onClick={() => onDirectionChange("LR")} title="Left → Right">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <rect x="2" y="9" width="6" height="6" rx="1"/><rect x="16" y="9" width="6" height="6" rx="1"/>
              <line x1="8" y1="12" x2="16" y2="12"/><polyline points="13 9 16 12 13 15"/>
            </svg>
            Left → Right
          </button>
          <button role="tab" aria-selected={direction === "TB"}
            className={cx("pv-jg-seg-opt", direction === "TB" && "is-active")}
            onClick={() => onDirectionChange("TB")} title="Top → Bottom">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <rect x="9" y="2" width="6" height="6" rx="1"/><rect x="9" y="16" width="6" height="6" rx="1"/>
              <line x1="12" y1="8" x2="12" y2="16"/><polyline points="9 13 12 16 15 13"/>
            </svg>
            Top → Bottom
          </button>
        </div>

        <div className="pv-jg-search">
          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
          <input
            className="pv-jg-search-input"
            placeholder="Search nodes…"
            value={graphSearch}
            onChange={e => setGraphSearch(e.target.value)}
          />
          {graphSearch && (
            <button className="pv-jg-search-clear" onClick={() => setGraphSearch('')} aria-label="Clear search">×</button>
          )}
          {graphSearch && <span className="pv-jg-search-count">{matchCount}</span>}
        </div>

        <span className="pv-jg-meta">{layout.nodes.length} nodes · {layout.edges.length} edges</span>

        <div className="pv-jg-controls">
          <button onClick={() => setZoom(z => Math.max(z - 0.1, 0.2))} aria-label="Zoom out">−</button>
          <span className="pv-jg-zoom">{Math.round(zoom * 100)}%</span>
          <button onClick={() => setZoom(z => Math.min(z + 0.1, 3))} aria-label="Zoom in">+</button>
          <button onClick={fitView}>Fit</button>
          <button onClick={toggleFullscreen} aria-label={isFullscreen ? "Exit fullscreen" : "Fullscreen"} title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}>
            {isFullscreen
              ? <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/></svg>
              : <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
            }
          </button>
        </div>
      </div>

      <div ref={canvasRef} className="pv-jg-canvas"
        onMouseDown={onMouseDown} onMouseMove={onMouseMove} onMouseUp={onMouseUp} onMouseLeave={onMouseUp}>
        <div className="pv-jg-stage" style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}>
          <svg className="pv-jg-edges" width={layout.width} height={layout.height}
            style={{ position: "absolute", inset: 0, pointerEvents: "none" }}>
            <defs>
              <marker id="pv-jg-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
                <path d="M 0 0 L 10 5 L 0 10 z" fill="var(--slate-400)" />
              </marker>
              <marker id="pv-jg-arrow-sel" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
                <path d="M 0 0 L 10 5 L 0 10 z" fill="var(--green-600)" />
              </marker>
              <marker id="pv-jg-arrow-rel" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
                <path d="M 0 0 L 10 5 L 0 10 z" fill="var(--warn-600, #d97706)" />
              </marker>
            </defs>
            {/* Cluster backgrounds */}
            {clusters.map(c => (
              <rect key={c.fid} x={c.x} y={c.y} width={c.w} height={c.h}
                rx="12" fill={c.fill} stroke={c.stroke} strokeWidth="1" />
            ))}
            {/* Edges */}
            {layout.edges.map((e, i) => {
              const isSel = selected != null && (e.from === selected || e.to === selected);
              const isCross = !!e.isCrossFile;
              const stroke = isSel ? "var(--green-600)" : isCross ? "var(--warn-600, #d97706)" : "var(--slate-300)";
              const marker = isSel ? "-sel" : isCross ? "-rel" : "";
              return (
                <path key={i}
                  d={edgePath(layout.nodes[e.from], layout.nodes[e.to], direction)}
                  stroke={stroke}
                  strokeWidth={isSel ? 1.8 : isCross ? 1.5 : 1.3}
                  strokeDasharray={isCross ? "5 3" : undefined}
                  fill="none"
                  markerEnd={`url(#pv-jg-arrow${marker})`}
                />
              );
            })}
          </svg>
          {layout.nodes.map((n, i) => {
            // Collect all loadable ref IDs: node's own id + any prop value that is a ref
            const seen = new Set();
            const loadableRefs = [];
            if (n.nodeKind !== 'root') {
              n.props.forEach(p => {
                const rt = refTypeOf(p.v);
                if (rt && !seen.has(p.v)) { seen.add(p.v); loadableRefs.push({ id: p.v, type: rt }); }
              });
            }
            return (
              <div key={i}
                className={cx("pv-jg-node", `kind-${n.nodeKind}`, selected === i && "is-selected", matchSet.has(i) && "is-match")}
                style={{ left: n.x, top: n.y, width: n.w }}
                onClick={(e) => { e.stopPropagation(); setSelected(i === selected ? null : i); }}>
                <div className="pv-jg-node-head">
                  <span className="pv-jg-node-key">
                    {n.nodeKind === 'root' ? (n.props.find(p => p.k === 'id')?.v || n.key) : n.key}
                  </span>
                  <span className="pv-jg-node-meta">{n.isArr ? `Array(${n.count})` : `Object(${n.count})`}</span>
                </div>
                {n.props.length > 0 && (
                  <ul className="pv-jg-node-props">
                    {n.props.map((p, j) => (
                      <li key={j}>
                        <span className="k">{p.k}</span>
                        <span className="v">{p.v === null ? "null" : String(p.v)}</span>
                      </li>
                    ))}
                  </ul>
                )}
                {loadableRefs.length > 0 && (
                  <div className="pv-jg-node-foot">
                    {loadableRefs.map(({ id: refId, type: refType }) => {
                      const isLoading = loadingNodes && loadingNodes.has(refId);
                      const isLoaded  = relatedFiles && (refId in relatedFiles);
                      return isLoading
                        ? <span key={refId} className="pv-spinner-sm" />
                        : isLoaded
                          ? <button key={refId} className="pv-jg-node-rel-btn is-hide" onClick={e => { e.stopPropagation(); onHideNode(refId); }}>
                              <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
                              Hide {refId}
                            </button>
                          : <button key={refId} className="pv-jg-node-rel-btn" onClick={e => { e.stopPropagation(); onLoadNode(refId, refType); }}>
                              <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="5 12 12 5 19 12"/><line x1="12" y1="5" x2="12" y2="19"/></svg>
                              Load {refType}
                            </button>;
                    })}
                  </div>
                )}
              </div>
            );
          })}
        </div>
        <div className="pv-jg-hint">drag to pan · scroll to zoom · click node to highlight edges</div>
      </div>
    </div>
  );
}

// -----------------------------------------------------------------------------
// Single-file layout

function layoutJsonGraph(json, direction) {
  const nodes = [], edges = [];

  function visit(value, key, parentIdx, depth) {
    if (value === null || typeof value !== "object") return;
    const isArr = Array.isArray(value);
    if (isArr && value.length === 0) return;
    const props = [], childObjects = [];
    if (isArr) {
      value.forEach((item, i) => {
        if (item !== null && typeof item === "object") childObjects.push({ k: `[${i}]`, v: item });
        else props.push({ k: `[${i}]`, v: item });
      });
    } else {
      for (const [k, v] of Object.entries(value)) {
        if (v !== null && typeof v === "object") childObjects.push({ k, v });
        else props.push({ k, v });
      }
    }
    const count = isArr ? value.length : Object.keys(value).length;
    const myIdx = nodes.length;
    const w = calcNodeW(key, props);
    nodes.push({ key, depth, isArr, props, count, nodeKind: depth === 0 ? "root" : isArr ? "array" : "object", x: 0, y: 0, w, h: HEADER_H + props.length * ROW_H + 6 });
    if (parentIdx != null) edges.push({ from: parentIdx, to: myIdx });
    for (const c of childObjects) visit(c.v, c.k, myIdx, depth + 1);
  }
  visit(json, "root", null, 0);

  const byDepth = {};
  for (let i = 0; i < nodes.length; i++) (byDepth[nodes[i].depth] ||= []).push(i);
  const maxDepth = Math.max(...Object.keys(byDepth).map(Number));

  if (direction === "LR") {
    const colW = {};
    for (let d = 0; d <= maxDepth; d++) colW[d] = Math.max(...(byDepth[d] || []).map(i => nodes[i].w), NODE_MIN_W);
    let x = 0, maxY = 0;
    for (let d = 0; d <= maxDepth; d++) {
      const col = byDepth[d] || [];
      let y = 0;
      for (const idx of col) { nodes[idx].x = x; nodes[idx].y = y; y += nodes[idx].h + ROW_GAP_LR; }
      maxY = Math.max(maxY, y);
      x += colW[d] + COL_GAP_LR;
    }
    return { nodes, edges, width: x, height: maxY };
  } else {
    let y = 0, maxX = 0;
    for (let d = 0; d <= maxDepth; d++) {
      const row = byDepth[d] || [];
      const maxH = row.reduce((acc, idx) => Math.max(acc, nodes[idx].h), 0);
      let x = 0;
      for (const idx of row) { nodes[idx].x = x; nodes[idx].y = y; x += nodes[idx].w + COL_GAP_TB; }
      maxX = Math.max(maxX, x);
      y += maxH + ROW_GAP_TB;
    }
    return { nodes, edges, width: maxX, height: y };
  }
}

// -----------------------------------------------------------------------------
// Multi-file layout: main (SET/GROUP) → groups → rules
// relatedFiles is a flat map { [id]: content }, grouped by ID prefix.

function layoutRelatedGraph(mainJson, mainType, relatedFiles, direction) {
  const groups = {}, rules = {}, sets = {};
  for (const [id, content] of Object.entries(relatedFiles || {})) {
    if (refTypeOf(id) === 'GROUP') groups[id] = content;
    else if (refTypeOf(id) === 'RULE') rules[id] = content;
    else if (refTypeOf(id) === 'SET') sets[id] = content;
  }

  const allNodes = [], allEdges = [];

  function place(layout, xOff, yOff, fileId) {
    const base = allNodes.length;
    layout.nodes.forEach(n => allNodes.push({ ...n, x: n.x + xOff, y: n.y + yOff, fileId }));
    layout.edges.forEach(e => allEdges.push({ from: e.from + base, to: e.to + base }));
    return { base, count: layout.nodes.length };
  }

  function findByIdProp(targetId, base, count) {
    for (let i = base; i < base + count; i++) {
      if (allNodes[i]?.props?.some(p => String(p.v) === targetId)) return i;
    }
    return -1;
  }

  const mainLayout = layoutJsonGraph(mainJson, direction);
  const main = place(mainLayout, 0, 0, '__main__');

  if (direction === 'LR') {
    // Column order: main | sets | groups | rules
    let curX = mainLayout.width + FILE_GAP;

    // Specs column
    let sy = 0, sMaxW = 0;
    const sInfo = {};
    for (const [sid, sc] of Object.entries(sets)) {
      const sl = layoutJsonGraph(sc, direction);
      sInfo[sid] = place(sl, curX, sy, sid);
      sMaxW = Math.max(sMaxW, sl.width);
      sy += sl.height + CLUSTER_GAP;
    }
    if (Object.keys(sets).length) curX += sMaxW + FILE_GAP;

    // Groups column
    let gy = 0, gMaxW = 0;
    const gInfo = {};
    for (const [gid, gc] of Object.entries(groups)) {
      const gl = layoutJsonGraph(gc, direction);
      gInfo[gid] = place(gl, curX, gy, gid);
      gMaxW = Math.max(gMaxW, gl.width);
      gy += gl.height + CLUSTER_GAP;
    }
    if (Object.keys(groups).length) curX += gMaxW + FILE_GAP;

    // Rules column
    let ry = 0, rMaxW = 0;
    const rInfo = {};
    for (const [rid, rc] of Object.entries(rules)) {
      const rl = layoutJsonGraph(rc, direction);
      rInfo[rid] = place(rl, curX, ry, rid);
      rMaxW = Math.max(rMaxW, rl.width);
      ry += rl.height + CLUSTER_GAP;
    }

    // Cross-file edges: main → sets
    for (const [sid, { base: sBase }] of Object.entries(sInfo)) {
      const src = findByIdProp(sid, main.base, main.count);
      if (src >= 0) allEdges.push({ from: src, to: sBase, isCrossFile: true });
    }
    // Cross-file edges: main/sets → groups
    for (const [gid, { base: gBase }] of Object.entries(gInfo)) {
      let src = findByIdProp(gid, main.base, main.count);
      if (src < 0) {
        for (const { base: sBase, count: sCount } of Object.values(sInfo)) {
          src = findByIdProp(gid, sBase, sCount);
          if (src >= 0) break;
        }
      }
      if (src >= 0) allEdges.push({ from: src, to: gBase, isCrossFile: true });
    }
    // Cross-file edges: groups/main/sets → rules
    for (const [rid, { base: rBase }] of Object.entries(rInfo)) {
      if (mainType === 'GROUP') {
        const src = findByIdProp(rid, main.base, main.count);
        if (src >= 0) allEdges.push({ from: src, to: rBase, isCrossFile: true });
      } else {
        let found = false;
        for (const { base: gBase, count: gCount } of Object.values(gInfo)) {
          const src = findByIdProp(rid, gBase, gCount);
          if (src >= 0) { allEdges.push({ from: src, to: rBase, isCrossFile: true }); found = true; break; }
        }
        if (!found) {
          for (const { base: sBase, count: sCount } of Object.values(sInfo)) {
            const src = findByIdProp(rid, sBase, sCount);
            if (src >= 0) { allEdges.push({ from: src, to: rBase, isCrossFile: true }); break; }
          }
        }
      }
    }

    return {
      nodes: allNodes, edges: allEdges,
      width: curX + rMaxW + 40,
      height: Math.max(mainLayout.height, sy, gy, ry) + 40,
    };
  } else { // TB
    // Row order: main | sets | groups | rules
    let curY = mainLayout.height + FILE_GAP;

    // Specs row
    let sx = 0, sMaxH = 0;
    const sInfo = {};
    for (const [sid, sc] of Object.entries(sets)) {
      const sl = layoutJsonGraph(sc, direction);
      sInfo[sid] = place(sl, sx, curY, sid);
      sMaxH = Math.max(sMaxH, sl.height);
      sx += sl.width + CLUSTER_GAP;
    }
    if (Object.keys(sets).length) curY += sMaxH + FILE_GAP;

    // Groups row
    let gx = 0, gMaxH = 0;
    const gInfo = {};
    for (const [gid, gc] of Object.entries(groups)) {
      const gl = layoutJsonGraph(gc, direction);
      gInfo[gid] = place(gl, gx, curY, gid);
      gMaxH = Math.max(gMaxH, gl.height);
      gx += gl.width + CLUSTER_GAP;
    }
    if (Object.keys(groups).length) curY += gMaxH + FILE_GAP;

    // Rules row
    let rx = 0, rMaxH = 0;
    const rInfo = {};
    for (const [rid, rc] of Object.entries(rules)) {
      const rl = layoutJsonGraph(rc, direction);
      rInfo[rid] = place(rl, rx, curY, rid);
      rMaxH = Math.max(rMaxH, rl.height);
      rx += rl.width + CLUSTER_GAP;
    }

    // Cross-file edges
    for (const [sid, { base: sBase }] of Object.entries(sInfo)) {
      const src = findByIdProp(sid, main.base, main.count);
      if (src >= 0) allEdges.push({ from: src, to: sBase, isCrossFile: true });
    }
    for (const [gid, { base: gBase }] of Object.entries(gInfo)) {
      let src = findByIdProp(gid, main.base, main.count);
      if (src < 0) {
        for (const { base: sBase, count: sCount } of Object.values(sInfo)) {
          src = findByIdProp(gid, sBase, sCount);
          if (src >= 0) break;
        }
      }
      if (src >= 0) allEdges.push({ from: src, to: gBase, isCrossFile: true });
    }
    for (const [rid, { base: rBase }] of Object.entries(rInfo)) {
      if (mainType === 'GROUP') {
        const src = findByIdProp(rid, main.base, main.count);
        if (src >= 0) allEdges.push({ from: src, to: rBase, isCrossFile: true });
      } else {
        let found = false;
        for (const { base: gBase, count: gCount } of Object.values(gInfo)) {
          const src = findByIdProp(rid, gBase, gCount);
          if (src >= 0) { allEdges.push({ from: src, to: rBase, isCrossFile: true }); found = true; break; }
        }
        if (!found) {
          for (const { base: sBase, count: sCount } of Object.values(sInfo)) {
            const src = findByIdProp(rid, sBase, sCount);
            if (src >= 0) { allEdges.push({ from: src, to: rBase, isCrossFile: true }); break; }
          }
        }
      }
    }

    return {
      nodes: allNodes, edges: allEdges,
      width: Math.max(mainLayout.width, sx, gx, rx) + 40,
      height: curY + rMaxH + 40,
    };
  }
}

// Bezier edge path
function edgePath(a, b, direction) {
  if (!a || !b) return '';
  if (direction === "LR") {
    const x1 = a.x + a.w, y1 = a.y + a.h / 2;
    const x2 = b.x, y2 = b.y + b.h / 2;
    const dx = Math.max(40, (x2 - x1) * 0.5);
    return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2 - 2} ${y2}`;
  } else {
    const x1 = a.x + a.w / 2, y1 = a.y + a.h;
    const x2 = b.x + b.w / 2, y2 = b.y;
    const dy = Math.max(40, (y2 - y1) * 0.5);
    return `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2 - 2}`;
  }
}

Object.assign(window, { GraphView });
