/* global React, ReactDOM, useTweaks, TweaksPanel, TweakSection, TweakRadio, TweakToggle, TweakSlider, TweakSelect */
const { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } = React;

/* ---------- Default JSON sample ---------- */
const SAMPLE = {
  "company": "Northwind Labs",
  "founded": 2019,
  "active": true,
  "headquarters": {
    "city": "Berlin",
    "country": "DE",
    "coords": [52.52, 13.405]
  },
  "team": [
    {
      "id": "u_001",
      "name": "Ada Chen",
      "role": "CEO",
      "skills": ["strategy", "fundraising"],
      "remote": false
    },
    {
      "id": "u_002",
      "name": "Bram Voss",
      "role": "CTO",
      "skills": ["distributed-systems", "rust", "go"],
      "remote": true
    }
  ],
  "products": {
    "alpha": { "stage": "ga", "arr": 1240000 },
    "beta":  { "stage": "beta", "arr": 88000 },
    "gamma": null
  },
  "tags": ["b2b", "infra", "ai"]
};

/* ---------- Type helpers ---------- */
const typeOf = (v) => {
  if (v === null) return "null";
  if (Array.isArray(v)) return "array";
  return typeof v;
};

const previewValue = (v) => {
  const t = typeOf(v);
  if (t === "string") return `"${v.length > 28 ? v.slice(0, 28) + "…" : v}"`;
  if (t === "null") return "null";
  if (t === "boolean") return v ? "true" : "false";
  return String(v);
};

/* ---------- Position-tracking JSON parser ---------- *
 * Returns { nodes, root } — nodes is the flat list used for layout.
 * Each node has entries; each entry has entryRange = [start,end] (the
 * "key": value span in source), and leaves carry valueRange too. Non-leaf
 * entries hold a childId; the child node carries its own valueRange.
 */
function parseToNodes(text) {
  let i = 0;
  const nodes = [];
  let nextId = 0;
  const N = text.length;

  function err(msg) {
    const line = text.slice(0, i).split("\n").length;
    const e = new SyntaxError(`Line ${line}: ${msg}`);
    e.pos = i;
    throw e;
  }
  function skipWs() {
    while (i < N) {
      const c = text.charCodeAt(i);
      if (c === 32 || c === 9 || c === 10 || c === 13) i++;
      else break;
    }
  }

  function parseString() {
    if (text[i] !== '"') err('Expected string');
    let j = i + 1;
    while (j < N) {
      const c = text[j];
      if (c === '\\') { j += 2; continue; }
      if (c === '"') break;
      if (c === '\n') err('Unterminated string');
      j++;
    }
    if (text[j] !== '"') err('Unterminated string');
    const raw = text.slice(i, j + 1);
    i = j + 1;
    try { return JSON.parse(raw); } catch (e) { err('Invalid string'); }
  }

  function parseNumber() {
    const m = text.slice(i).match(/^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/);
    if (!m) err('Expected number');
    i += m[0].length;
    return Number(m[0]);
  }

  function parseKeyword(word, value) {
    if (text.slice(i, i + word.length) !== word) err(`Expected ${word}`);
    i += word.length;
    return value;
  }

  /** Returns { leaf: boolean, value?, childId?, valueRange } */
  function parseValue(label, parentId) {
    skipWs();
    const start = i;
    if (i >= N) err('Unexpected end of input');
    const c = text[i];
    if (c === '{') {
      const childId = parseObject(label, parentId, start);
      return { leaf: false, childId, valueRange: [start, i] };
    }
    if (c === '[') {
      const childId = parseArray(label, parentId, start);
      return { leaf: false, childId, valueRange: [start, i] };
    }
    let value;
    if (c === '"') value = parseString();
    else if (c === 't') value = parseKeyword('true', true);
    else if (c === 'f') value = parseKeyword('false', false);
    else if (c === 'n') value = parseKeyword('null', null);
    else if (c === '-' || (c >= '0' && c <= '9')) value = parseNumber();
    else err(`Unexpected token '${c}'`);
    return { leaf: true, value, valueRange: [start, i] };
  }

  function parseObject(label, parentId, startPos) {
    if (text[i] !== '{') err('Expected {');
    i++;
    const id = `n${nextId++}`;
    const node = {
      id, label, type: 'object',
      parentId, entries: [],
      valueRange: [startPos, startPos],
    };
    nodes.push(node);
    skipWs();
    if (text[i] === '}') {
      i++;
      node.valueRange = [startPos, i];
      return id;
    }
    while (true) {
      skipWs();
      const entryStart = i;
      if (text[i] !== '"') err('Expected string key');
      const key = parseString();
      skipWs();
      if (text[i] !== ':') err('Expected :');
      i++;
      const result = parseValue(key, id);
      const entryEnd = i;
      const entry = {
        key, leaf: result.leaf,
        entryRange: [entryStart, entryEnd],
        valueRange: result.valueRange,
        index: false,
      };
      if (result.leaf) entry.value = result.value;
      else entry.childId = result.childId;
      node.entries.push(entry);

      skipWs();
      if (text[i] === ',') { i++; continue; }
      if (text[i] === '}') { i++; break; }
      err('Expected , or }');
    }
    node.valueRange = [startPos, i];
    return id;
  }

  function parseArray(label, parentId, startPos) {
    if (text[i] !== '[') err('Expected [');
    i++;
    const id = `n${nextId++}`;
    const node = {
      id, label, type: 'array',
      parentId, entries: [],
      valueRange: [startPos, startPos],
    };
    nodes.push(node);
    skipWs();
    if (text[i] === ']') {
      i++;
      node.valueRange = [startPos, i];
      return id;
    }
    let idx = 0;
    while (true) {
      skipWs();
      const entryStart = i;
      const result = parseValue(String(idx), id);
      const entryEnd = i;
      const entry = {
        key: String(idx), leaf: result.leaf,
        entryRange: [entryStart, entryEnd],
        valueRange: result.valueRange,
        index: true,
      };
      if (result.leaf) entry.value = result.value;
      else entry.childId = result.childId;
      node.entries.push(entry);
      idx++;
      skipWs();
      if (text[i] === ',') { i++; continue; }
      if (text[i] === ']') { i++; break; }
      err('Expected , or ]');
    }
    node.valueRange = [startPos, i];
    return id;
  }

  function parseRoot() {
    skipWs();
    if (i >= N) err('Empty input');
    const result = parseValue('root', null);
    skipWs();
    if (i < N) err('Unexpected trailing content');
    if (result.leaf) {
      // wrap scalar root in a synthetic node so the canvas has something to show
      const id = `n${nextId++}`;
      nodes.push({
        id, label: 'root', type: typeOf(result.value),
        parentId: null,
        entries: [{
          key: 'value', leaf: true, value: result.value,
          entryRange: result.valueRange,
          valueRange: result.valueRange,
          index: false,
        }],
        valueRange: result.valueRange,
      });
      return { nodes, rootId: id };
    }
    return { nodes, rootId: result.childId };
  }

  return parseRoot();
}

/* ---------- Layout (left-to-right tidy tree) ---------- */
const COL_GAP = 80;
const ROW_GAP = 20;
const NODE_W = 280;
const HEADER_H = 38;
const ROW_H = 28;
const NODE_PADDING_Y = 8;

function measureNode(node) {
  const rows = Math.max(1, node.entries.length);
  return HEADER_H + NODE_PADDING_Y * 2 + rows * ROW_H;
}

function layoutGraph(nodes, rootId, direction = "LR") {
  if (!nodes.length) return { nodes: [], bounds: { minX: 0, minY: 0, maxX: 0, maxY: 0 } };
  const byId = new Map(nodes.map(n => [n.id, n]));
  const childrenOf = (id) => byId.get(id).entries.filter(e => e.childId).map(e => e.childId);

  const depth = new Map();
  const stack = [[rootId, 0]];
  while (stack.length) {
    const [id, d] = stack.pop();
    depth.set(id, d);
    for (const c of childrenOf(id)) stack.push([c, d + 1]);
  }

  const byDepth = new Map();
  for (const [id, d] of depth) {
    if (!byDepth.has(d)) byDepth.set(d, []);
    byDepth.get(d).push(id);
  }

  const positions = new Map();
  let leafCursor = 0;
  function place(id) {
    const node = byId.get(id);
    const h = measureNode(node);
    const kids = childrenOf(id);
    if (kids.length === 0) {
      const y = leafCursor;
      leafCursor += h + ROW_GAP;
      positions.set(id, { y, h });
      return { top: y, bottom: y + h };
    }
    let firstTop = Infinity, lastBottom = -Infinity;
    for (const c of kids) {
      const r = place(c);
      firstTop = Math.min(firstTop, r.top);
      lastBottom = Math.max(lastBottom, r.bottom);
    }
    const centerY = (firstTop + lastBottom) / 2;
    const y = centerY - h / 2;
    positions.set(id, { y, h });
    leafCursor = Math.max(leafCursor, lastBottom + ROW_GAP);
    return { top: Math.min(y, firstTop), bottom: Math.max(y + h, lastBottom) };
  }
  place(rootId);

  const depths = [...byDepth.keys()].sort((a, b) => a - b);
  for (const d of depths) {
    const ids = byDepth.get(d).slice().sort((a, b) => positions.get(a).y - positions.get(b).y);
    let cursor = -Infinity;
    for (const id of ids) {
      const p = positions.get(id);
      if (p.y < cursor) p.y = cursor;
      cursor = p.y + p.h + ROW_GAP;
    }
  }

  const result = nodes.map(n => {
    if (!positions.has(n.id)) return null;
    const p = positions.get(n.id);
    const d = depth.get(n.id);
    const x = d * (NODE_W + COL_GAP);
    return { ...n, x, y: p.y, w: NODE_W, h: p.h };
  }).filter(Boolean);

  const minX = Math.min(...result.map(r => r.x));
  const minY = Math.min(...result.map(r => r.y));
  const maxX = Math.max(...result.map(r => r.x + r.w));
  const maxY = Math.max(...result.map(r => r.y + r.h));

  if (direction === "TB") {
    const swapped = result.map(r => ({ ...r, x: r.y, y: r.x }));
    const sminX = Math.min(...swapped.map(r => r.x));
    const sminY = Math.min(...swapped.map(r => r.y));
    const smaxX = Math.max(...swapped.map(r => r.x + r.w));
    const smaxY = Math.max(...swapped.map(r => r.y + r.h));
    return { nodes: swapped, bounds: { minX: sminX, minY: sminY, maxX: smaxX, maxY: smaxY } };
  }

  return { nodes: result, bounds: { minX, minY, maxX, maxY } };
}

/* ---------- Edge path ---------- */
function edgePath(x1, y1, x2, y2, style = "bezier") {
  if (style === "step") {
    const mx = (x1 + x2) / 2;
    return `M ${x1} ${y1} L ${mx} ${y1} L ${mx} ${y2} L ${x2} ${y2}`;
  }
  if (style === "straight") return `M ${x1} ${y1} L ${x2} ${y2}`;
  const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
  return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}

/* ---------- Type colors ---------- */
const TYPE_META = {
  object:  { label: "obj",  color: "#7dd3fc" },
  array:   { label: "arr",  color: "#c4b5fd" },
  string:  { label: "str",  color: "#86efac" },
  number:  { label: "num",  color: "#fdba74" },
  boolean: { label: "bool", color: "#f9a8d4" },
  null:    { label: "null", color: "#94a3b8" },
};

/* ---------- Node card ---------- */
function NodeCard({ node, selection, onSelectNode, onSelectEntry, onHoverEdge, hoveredEdgeKey }) {
  const meta = TYPE_META[node.type];
  const countLabel =
    node.type === "object" ? `${node.entries.length} keys` :
    node.type === "array"  ? `${node.entries.length} items` :
    "value";

  const nodeSelected = selection && selection.nodeId === node.id && selection.entryIdx == null;
  const childOfSelectedEdge = hoveredEdgeKey && hoveredEdgeKey.endsWith(":" + node.id);

  return (
    <div
      className="node"
      data-selected={nodeSelected ? "true" : "false"}
      data-edge-hover={childOfSelectedEdge ? "true" : "false"}
      style={{
        left: node.x,
        top: node.y,
        width: node.w,
        height: node.h,
        "--type-color": meta.color,
      }}
      onMouseDown={(e) => e.stopPropagation()}
    >
      <div
        className="node-header"
        onClick={(e) => { e.stopPropagation(); onSelectNode(node.id); }}
      >
        <span className="node-type">{meta.label}</span>
        <span className="node-label">{node.label}</span>
        <span className="node-count">{countLabel}</span>
      </div>
      <div className="node-body">
        {node.entries.map((e, i) => {
          const t = e.leaf ? typeOf(e.value) : (e.childId ? null : null);
          const tm = e.leaf ? TYPE_META[t] : null;
          const edgeKey = e.childId ? `${node.id}:${e.childId}` : null;
          const isHovered = edgeKey && hoveredEdgeKey === edgeKey;
          const entrySelected = selection && selection.nodeId === node.id && selection.entryIdx === i;
          return (
            <div
              key={i}
              className="entry"
              data-leaf={e.leaf ? "true" : "false"}
              data-hover={isHovered ? "true" : "false"}
              data-selected={entrySelected ? "true" : "false"}
              onMouseEnter={() => onHoverEdge && onHoverEdge(edgeKey)}
              onMouseLeave={() => onHoverEdge && onHoverEdge(null)}
              onClick={(ev) => { ev.stopPropagation(); onSelectEntry(node.id, i); }}
            >
              <span className="entry-key">
                {e.index ? <span className="entry-idx">{e.key}</span> : e.key}
              </span>
              {e.leaf ? (
                <span className="entry-val" style={{ "--type-color": tm.color }}>
                  {previewValue(e.value)}
                </span>
              ) : (
                <RefBadge node={node} entry={e} />
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

function RefBadge({ entry }) {
  // child type/count is derived in Canvas via lookup, but we keep entry self-contained
  // by reading childMeta passed via context-free attribute on entry (set in Canvas).
  const meta = TYPE_META[entry._childType || "object"];
  return (
    <span className="entry-ref" style={{ "--type-color": meta.color }}>
      {entry._childType === "array" ? `[ ${entry._childCount} ]` : `{ ${entry._childCount} }`}
      <span className="entry-arrow">→</span>
    </span>
  );
}

/* ---------- Editor with line numbers + highlight overlay ---------- */
function Editor({ value, onChange, error, highlightRange }) {
  const taRef = useRef(null);
  const gutterRef = useRef(null);
  const overlayRef = useRef(null);

  const lines = useMemo(() => value.split("\n").length, [value]);

  const syncScroll = () => {
    if (gutterRef.current && taRef.current) gutterRef.current.scrollTop = taRef.current.scrollTop;
    if (overlayRef.current && taRef.current) {
      overlayRef.current.scrollTop = taRef.current.scrollTop;
      overlayRef.current.scrollLeft = taRef.current.scrollLeft;
    }
  };

  // Scroll selected range into view
  useEffect(() => {
    if (!highlightRange || !taRef.current) return;
    const [start] = highlightRange;
    const lineIdx = value.slice(0, start).split("\n").length - 1;
    const lineHeight = 20;
    const target = lineIdx * lineHeight;
    const ta = taRef.current;
    const visibleTop = ta.scrollTop;
    const visibleBottom = visibleTop + ta.clientHeight - 40;
    if (target < visibleTop + 20 || target > visibleBottom) {
      ta.scrollTo({ top: Math.max(0, target - 120), behavior: "smooth" });
    }
  }, [highlightRange, value]);

  const handleKeyDown = (e) => {
    if (e.key === "Tab") {
      e.preventDefault();
      const ta = taRef.current;
      const start = ta.selectionStart;
      const end = ta.selectionEnd;
      const next = value.slice(0, start) + "  " + value.slice(end);
      onChange(next);
      requestAnimationFrame(() => {
        ta.selectionStart = ta.selectionEnd = start + 2;
      });
    }
  };

  // Build overlay parts
  let before = value, marked = "", after = "";
  if (highlightRange) {
    const [s, e] = highlightRange;
    before = value.slice(0, s);
    marked = value.slice(s, e);
    after = value.slice(e);
  }

  return (
    <div className="editor">
      <div className="gutter" ref={gutterRef}>
        {Array.from({ length: lines }).map((_, i) => (
          <div key={i} className="gutter-line">{i + 1}</div>
        ))}
      </div>
      <div className="editor-stack">
        <div className="highlight-layer" ref={overlayRef} aria-hidden="true">
          <span className="hl-before">{before}</span>
          {highlightRange ? <mark className="hl-mark">{marked || " "}</mark> : null}
          <span className="hl-after">{after}</span>
        </div>
        <textarea
          ref={taRef}
          className="editor-area"
          value={value}
          onChange={(e) => onChange(e.target.value)}
          onScroll={syncScroll}
          onKeyDown={handleKeyDown}
          spellCheck={false}
          autoCorrect="off"
          autoCapitalize="off"
        />
      </div>
      {error && (
        <div className="editor-error">
          <span className="dot" />
          {error}
        </div>
      )}
    </div>
  );
}

/* ---------- Canvas with pan/zoom ---------- */
function Canvas({ layout, edgeStyle, showMinimap, selection, onSelectNode, onSelectEntry, onClearSelection }) {
  const wrapRef = useRef(null);
  const [transform, setTransform] = useState({ x: 40, y: 40, k: 1 });
  const dragRef = useRef(null);
  const [hoveredEdgeKey, setHoveredEdgeKey] = useState(null);

  // Enrich entries with child meta for the badge
  const enrichedNodes = useMemo(() => {
    if (!layout) return [];
    const byId = new Map(layout.nodes.map(n => [n.id, n]));
    return layout.nodes.map(n => ({
      ...n,
      entries: n.entries.map(e => {
        if (e.leaf) return e;
        const child = byId.get(e.childId);
        return { ...e, _childType: child ? child.type : "object", _childCount: child ? child.entries.length : 0 };
      })
    }));
  }, [layout]);

  useLayoutEffect(() => {
    if (!wrapRef.current || !layout) return;
    const rect = wrapRef.current.getBoundingClientRect();
    const b = layout.bounds;
    const w = b.maxX - b.minX + 80;
    const h = b.maxY - b.minY + 80;
    const k = Math.min(rect.width / w, rect.height / h, 1);
    const x = (rect.width - w * k) / 2 - b.minX * k + 40 * k;
    const y = (rect.height - h * k) / 2 - b.minY * k + 40 * k;
    setTransform({ x, y, k });
  }, [layout && layout.bounds.maxX, layout && layout.bounds.maxY]);

  const onMouseDown = (e) => {
    if (e.button !== 0) return;
    onClearSelection && onClearSelection();
    dragRef.current = { sx: e.clientX, sy: e.clientY, tx: transform.x, ty: transform.y, moved: false };
  };
  const onMouseMove = (e) => {
    if (!dragRef.current) return;
    const dx = e.clientX - dragRef.current.sx;
    const dy = e.clientY - dragRef.current.sy;
    if (Math.abs(dx) > 2 || Math.abs(dy) > 2) dragRef.current.moved = true;
    setTransform(t => ({ ...t, x: dragRef.current.tx + dx, y: dragRef.current.ty + dy }));
  };
  const onMouseUp = () => { dragRef.current = null; };

  useEffect(() => {
    const el = wrapRef.current;
    if (!el) return;
    const onWheel = (e) => {
      e.preventDefault();
      const rect = el.getBoundingClientRect();
      const mx = e.clientX - rect.left;
      const my = e.clientY - rect.top;
      const delta = -e.deltaY * 0.0015;
      setTransform(t => {
        const nk = Math.max(0.15, Math.min(2.5, t.k * (1 + delta)));
        const sx = (mx - t.x) / t.k;
        const sy = (my - t.y) / t.k;
        return { x: mx - sx * nk, y: my - sy * nk, k: nk };
      });
    };
    el.addEventListener("wheel", onWheel, { passive: false });
    return () => el.removeEventListener("wheel", onWheel);
  }, []);

  const fitToView = () => {
    if (!wrapRef.current || !layout) return;
    const rect = wrapRef.current.getBoundingClientRect();
    const b = layout.bounds;
    const w = b.maxX - b.minX + 80;
    const h = b.maxY - b.minY + 80;
    const k = Math.min(rect.width / w, rect.height / h, 1);
    const x = (rect.width - w * k) / 2 - b.minX * k + 40 * k;
    const y = (rect.height - h * k) / 2 - b.minY * k + 40 * k;
    setTransform({ x, y, k });
  };

  const zoom = (factor) => {
    const rect = wrapRef.current.getBoundingClientRect();
    const mx = rect.width / 2;
    const my = rect.height / 2;
    setTransform(t => {
      const nk = Math.max(0.15, Math.min(2.5, t.k * factor));
      const sx = (mx - t.x) / t.k;
      const sy = (my - t.y) / t.k;
      return { x: mx - sx * nk, y: my - sy * nk, k: nk };
    });
  };

  // When selection changes (from external source), pan to bring selected node into view
  useEffect(() => {
    if (!selection || !layout || !wrapRef.current) return;
    const node = enrichedNodes.find(n => n.id === selection.nodeId);
    if (!node) return;
    const rect = wrapRef.current.getBoundingClientRect();
    // node center in stage coords
    const cx = node.x + node.w / 2;
    const cy = node.y + node.h / 2;
    // current screen-space center of node
    setTransform(t => {
      const sx = cx * t.k + t.x;
      const sy = cy * t.k + t.y;
      const targetX = rect.width / 2;
      const targetY = rect.height / 2;
      const dx = targetX - sx;
      const dy = targetY - sy;
      // only pan if it's off-screen
      const nodeLeft = node.x * t.k + t.x;
      const nodeRight = (node.x + node.w) * t.k + t.x;
      const nodeTop = node.y * t.k + t.y;
      const nodeBot = (node.y + node.h) * t.k + t.y;
      const offscreen = nodeRight < 40 || nodeLeft > rect.width - 40 || nodeBot < 40 || nodeTop > rect.height - 40;
      if (!offscreen) return t;
      return { ...t, x: t.x + dx, y: t.y + dy };
    });
  }, [selection && selection.nodeId]);

  const edges = useMemo(() => {
    if (!layout) return [];
    const byId = new Map(enrichedNodes.map(n => [n.id, n]));
    const list = [];
    for (const n of enrichedNodes) {
      n.entries.forEach((e, i) => {
        if (!e.childId) return;
        const child = byId.get(e.childId);
        if (!child) return;
        const rowY = n.y + HEADER_H + NODE_PADDING_Y + i * ROW_H + ROW_H / 2;
        const x1 = n.x + n.w;
        const y1 = rowY;
        const x2 = child.x;
        const y2 = child.y + HEADER_H / 2 + NODE_PADDING_Y;
        list.push({
          key: `${n.id}:${child.id}`,
          d: edgePath(x1, y1, x2, y2, edgeStyle),
          type: child.type,
          srcNode: n.id, dstNode: child.id, entryIdx: i,
        });
      });
    }
    return list;
  }, [enrichedNodes, edgeStyle]);

  if (!layout) return null;

  // edges connected to selected node
  const selectedEdgeKey = selection ? (
    selection.entryIdx != null
      ? (() => {
          const node = enrichedNodes.find(n => n.id === selection.nodeId);
          const entry = node && node.entries[selection.entryIdx];
          return entry && entry.childId ? `${selection.nodeId}:${entry.childId}` : null;
        })()
      : null
  ) : null;

  return (
    <div
      className="canvas-wrap"
      ref={wrapRef}
      onMouseDown={onMouseDown}
      onMouseMove={onMouseMove}
      onMouseUp={onMouseUp}
      onMouseLeave={onMouseUp}
    >
      <div className="canvas-grid" />
      <div
        className="canvas-stage"
        style={{
          transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.k})`,
        }}
      >
        <svg className="edges" style={{
          left: layout.bounds.minX - 200,
          top: layout.bounds.minY - 200,
          width: layout.bounds.maxX - layout.bounds.minX + 400,
          height: layout.bounds.maxY - layout.bounds.minY + 400,
        }} viewBox={`${layout.bounds.minX - 200} ${layout.bounds.minY - 200} ${layout.bounds.maxX - layout.bounds.minX + 400} ${layout.bounds.maxY - layout.bounds.minY + 400}`}>
          <defs>
            <marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto" markerUnits="userSpaceOnUse">
              <path d="M 0 0 L 8 4 L 0 8 z" fill="currentColor" opacity="0.6" />
            </marker>
          </defs>
          {edges.map(e => {
            const isSelected = selectedEdgeKey === e.key ||
              (selection && (selection.nodeId === e.srcNode || selection.nodeId === e.dstNode));
            const isHovered = hoveredEdgeKey === e.key;
            const active = isHovered || isSelected;
            const dim = (hoveredEdgeKey || selection) && !active;
            return (
              <path
                key={e.key}
                d={e.d}
                fill="none"
                stroke={TYPE_META[e.type].color}
                strokeWidth={active ? 2.4 : 1.4}
                strokeOpacity={dim ? 0.15 : (active ? 1 : 0.55)}
                markerEnd="url(#arrow)"
                style={{ color: TYPE_META[e.type].color, transition: "stroke-width .15s, stroke-opacity .15s" }}
              />
            );
          })}
        </svg>
        {enrichedNodes.map(n => (
          <NodeCard
            key={n.id}
            node={n}
            selection={selection}
            hoveredEdgeKey={hoveredEdgeKey}
            onHoverEdge={setHoveredEdgeKey}
            onSelectNode={onSelectNode}
            onSelectEntry={onSelectEntry}
          />
        ))}
      </div>

      <div className="canvas-controls">
        <button onClick={() => zoom(1.2)} title="Zoom in">＋</button>
        <button onClick={() => zoom(1/1.2)} title="Zoom out">−</button>
        <button onClick={fitToView} title="Fit to view">⤢</button>
        <div className="zoom-label">{Math.round(transform.k * 100)}%</div>
      </div>

      {showMinimap && (
        <Minimap layout={layout} transform={transform} wrapRef={wrapRef} selection={selection} />
      )}
    </div>
  );
}

function Minimap({ layout, transform, wrapRef, selection }) {
  const b = layout.bounds;
  const pad = 40;
  const w = b.maxX - b.minX + pad * 2;
  const h = b.maxY - b.minY + pad * 2;
  const mmW = 180;
  const mmH = Math.min(140, mmW * (h / w));
  const scale = mmW / w;

  const rect = wrapRef.current ? wrapRef.current.getBoundingClientRect() : { width: 800, height: 600 };
  const viewW = rect.width / transform.k;
  const viewH = rect.height / transform.k;
  const viewX = -transform.x / transform.k - b.minX + pad;
  const viewY = -transform.y / transform.k - b.minY + pad;

  return (
    <div className="minimap" style={{ width: mmW, height: mmH }}>
      <svg width={mmW} height={mmH}>
        {layout.nodes.map(n => {
          const isSel = selection && selection.nodeId === n.id;
          return (
            <rect
              key={n.id}
              x={(n.x - b.minX + pad) * scale}
              y={(n.y - b.minY + pad) * scale}
              width={n.w * scale}
              height={n.h * scale}
              fill={isSel ? "var(--accent)" : TYPE_META[n.type].color}
              opacity={isSel ? 1 : 0.5}
              rx={1}
            />
          );
        })}
        <rect
          x={viewX * scale}
          y={viewY * scale}
          width={viewW * scale}
          height={viewH * scale}
          fill="none"
          stroke="var(--accent)"
          strokeWidth={1.5}
        />
      </svg>
    </div>
  );
}

/* ---------- Toolbar ---------- */
function Toolbar({ onFormat, onMinify, onSample, onClear, onPaste, stats }) {
  return (
    <div className="toolbar">
      <div className="brand">
        <div className="brand-mark">
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
            <circle cx="5" cy="6" r="2.5" fill="#7dd3fc" />
            <circle cx="5" cy="18" r="2.5" fill="#c4b5fd" />
            <circle cx="19" cy="12" r="2.5" fill="#86efac" />
            <path d="M7.2 7.2 L17 11" stroke="#7dd3fc" strokeWidth="1.4" opacity="0.7" />
            <path d="M7.2 16.8 L17 13" stroke="#c4b5fd" strokeWidth="1.4" opacity="0.7" />
          </svg>
        </div>
        <div className="brand-text">
          <div className="brand-title">JSON Node</div>
          <div className="brand-sub">visualize • inspect • explore</div>
        </div>
      </div>
      <div className="toolbar-actions">
        <button onClick={onFormat}>Format</button>
        <button onClick={onMinify}>Minify</button>
        <button onClick={onPaste}>Paste</button>
        <button onClick={onSample}>Sample</button>
        <button onClick={onClear} className="ghost">Clear</button>
      </div>
      <div className="stats">
        {stats && (
          <>
            <span><b>{stats.nodes}</b> nodes</span>
            <span><b>{stats.leaves}</b> values</span>
            <span><b>{stats.depth}</b> levels</span>
            <span><b>{stats.size}</b></span>
          </>
        )}
      </div>
    </div>
  );
}

/* ---------- Main app ---------- */
function App() {
  const [tweaks, setTweak] = useTweaks(/*EDITMODE-BEGIN*/{
    "theme": "dark",
    "direction": "LR",
    "edgeStyle": "bezier",
    "minimap": true,
    "splitRatio": 38
  }/*EDITMODE-END*/);

  const [text, setText] = useState(JSON.stringify(SAMPLE, null, 2));
  const [parseResult, setParseResult] = useState(() => {
    try { return parseToNodes(JSON.stringify(SAMPLE, null, 2)); } catch { return null; }
  });
  const [error, setError] = useState(null);
  const [selection, setSelection] = useState(null); // { nodeId, entryIdx? }

  // Debounced parse
  useEffect(() => {
    const t = setTimeout(() => {
      try {
        const result = parseToNodes(text);
        setParseResult(result);
        setError(null);
      } catch (e) {
        setError(e.message);
      }
    }, 150);
    return () => clearTimeout(t);
  }, [text]);

  // Clear selection on parse error or when selected node disappears
  useEffect(() => {
    if (!parseResult || !selection) return;
    const node = parseResult.nodes.find(n => n.id === selection.nodeId);
    if (!node) { setSelection(null); return; }
    if (selection.entryIdx != null && !node.entries[selection.entryIdx]) {
      setSelection({ nodeId: selection.nodeId });
    }
  }, [parseResult]);

  const layout = useMemo(
    () => parseResult ? layoutGraph(parseResult.nodes, parseResult.rootId, tweaks.direction) : null,
    [parseResult, tweaks.direction]
  );

  const stats = useMemo(() => {
    if (!parseResult) return null;
    let leaves = 0, maxDepth = 0;
    const byId = new Map(parseResult.nodes.map(n => [n.id, n]));
    function walk(id, d) {
      maxDepth = Math.max(maxDepth, d);
      const n = byId.get(id); if (!n) return;
      for (const e of n.entries) {
        if (e.leaf) leaves++;
        else walk(e.childId, d + 1);
      }
    }
    walk(parseResult.rootId, 0);
    const bytes = new Blob([text]).size;
    const size = bytes > 1024 ? `${(bytes/1024).toFixed(1)} KB` : `${bytes} B`;
    return { nodes: parseResult.nodes.length, leaves, depth: maxDepth + 1, size };
  }, [parseResult, text]);

  const highlightRange = useMemo(() => {
    if (!selection || !parseResult) return null;
    const node = parseResult.nodes.find(n => n.id === selection.nodeId);
    if (!node) return null;
    if (selection.entryIdx != null) {
      const entry = node.entries[selection.entryIdx];
      return entry ? entry.entryRange : null;
    }
    return node.valueRange;
  }, [selection, parseResult]);

  const onFormat = () => {
    try { setText(JSON.stringify(JSON.parse(text), null, 2)); setSelection(null); } catch {}
  };
  const onMinify = () => {
    try { setText(JSON.stringify(JSON.parse(text))); setSelection(null); } catch {}
  };
  const onSample = () => { setText(JSON.stringify(SAMPLE, null, 2)); setSelection(null); };
  const onClear = () => { setText("{}"); setSelection(null); };
  const onPaste = async () => {
    try { setText(await navigator.clipboard.readText()); setSelection(null); } catch {}
  };

  // Split drag
  const [dragSplit, setDragSplit] = useState(false);
  const splitRef = useRef(null);
  useEffect(() => {
    if (!dragSplit) return;
    const move = (e) => {
      const rect = splitRef.current.getBoundingClientRect();
      const pct = ((e.clientX - rect.left) / rect.width) * 100;
      setTweak("splitRatio", Math.max(20, Math.min(70, pct)));
    };
    const up = () => setDragSplit(false);
    window.addEventListener("mousemove", move);
    window.addEventListener("mouseup", up);
    return () => {
      window.removeEventListener("mousemove", move);
      window.removeEventListener("mouseup", up);
    };
  }, [dragSplit, setTweak]);

  return (
    <div className={`app theme-${tweaks.theme}`}>
      <Toolbar
        onFormat={onFormat}
        onMinify={onMinify}
        onSample={onSample}
        onClear={onClear}
        onPaste={onPaste}
        stats={stats}
      />
      <div className="split" ref={splitRef}>
        <div className="pane editor-pane" style={{ width: `${tweaks.splitRatio}%` }}>
          <div className="pane-header">
            <span className="pane-title">input.json</span>
            <span className={`pane-status ${error ? "err" : "ok"}`}>
              {error ? "invalid" : "valid"}
            </span>
          </div>
          <Editor value={text} onChange={setText} error={error} highlightRange={highlightRange} />
        </div>
        <div
          className="split-handle"
          onMouseDown={() => setDragSplit(true)}
          data-dragging={dragSplit ? "true" : "false"}
        />
        <div className="pane graph-pane" style={{ width: `${100 - tweaks.splitRatio}%` }}>
          <div className="pane-header">
            <span className="pane-title">
              graph
              {selection && parseResult && (() => {
                const node = parseResult.nodes.find(n => n.id === selection.nodeId);
                if (!node) return null;
                const label = selection.entryIdx != null
                  ? `${node.label} › ${node.entries[selection.entryIdx]?.key ?? ""}`
                  : node.label;
                return (
                  <span className="selection-pill">
                    <span className="dot" /> {label}
                    <button className="x" onClick={() => setSelection(null)}>×</button>
                  </span>
                );
              })()}
            </span>
            <span className="legend">
              {Object.entries(TYPE_META).map(([k, m]) => (
                <span key={k} className="legend-item">
                  <span className="dot" style={{ background: m.color }} />
                  {m.label}
                </span>
              ))}
            </span>
          </div>
          <Canvas
            layout={layout}
            edgeStyle={tweaks.edgeStyle}
            showMinimap={tweaks.minimap}
            selection={selection}
            onSelectNode={(id) => setSelection({ nodeId: id })}
            onSelectEntry={(id, idx) => setSelection({ nodeId: id, entryIdx: idx })}
            onClearSelection={() => setSelection(null)}
          />
        </div>
      </div>

      <TweaksPanel title="Tweaks">
        <TweakSection title="Appearance">
          <TweakRadio
            label="Theme"
            value={tweaks.theme}
            options={[
              { value: "dark", label: "Dark" },
              { value: "light", label: "Light" },
            ]}
            onChange={(v) => setTweak("theme", v)}
          />
        </TweakSection>
        <TweakSection title="Graph">
          <TweakRadio
            label="Direction"
            value={tweaks.direction}
            options={[
              { value: "LR", label: "Left → Right" },
              { value: "TB", label: "Top ↓ Bottom" },
            ]}
            onChange={(v) => setTweak("direction", v)}
          />
          <TweakSelect
            label="Edge style"
            value={tweaks.edgeStyle}
            options={[
              { value: "bezier", label: "Bezier curve" },
              { value: "step", label: "Right angle" },
              { value: "straight", label: "Straight line" },
            ]}
            onChange={(v) => setTweak("edgeStyle", v)}
          />
          <TweakToggle
            label="Show minimap"
            value={tweaks.minimap}
            onChange={(v) => setTweak("minimap", v)}
          />
        </TweakSection>
        <TweakSection title="Layout">
          <TweakSlider
            label="Editor width"
            value={tweaks.splitRatio}
            min={20}
            max={70}
            step={1}
            onChange={(v) => setTweak("splitRatio", v)}
            unit="%"
          />
        </TweakSection>
      </TweaksPanel>
    </div>
  );
}

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);
