// ============================================================================
// public/screens/flow-builder.jsx — Visual Flow Builder v2 (Phase 4)
// ----------------------------------------------------------------------------
// Major upgrade from v1:
//   • Node types now driven by backend registry (/api/flows/node-types)
//   • 21 node types (12 original + 9 new) auto-rendered from schema
//   • Advanced/simple config mode toggle per node
//   • Drag-from-palette (replaces click-add)
//   • Keyboard shortcuts: Ctrl+Z/Y (undo/redo), Ctrl+C/V/D (copy/paste/dup),
//     Delete (remove), Ctrl+F (search), Ctrl+A (select all)
//   • Right-click context menu on nodes
//   • Multi-select via Shift+click or rubber-band drag
//   • Minimap (bottom-right) with click-to-jump
//   • Auto-layout button (top-down hierarchical)
//   • Search panel (Ctrl+F)
// ============================================================================

// Node config field types we support in the auto-form renderer:
//   text, textarea, number, select, toggle, tags, kvList, conditionList,
//   varOpList, variable, json, kbCollection, modelSelect, integrationSelect,
//   flowSelect
//
// The registry comes from the backend so adding a new node type is just an
// edit to shared/flow-node-registry.js — no frontend change needed.

const NODE_W = 200, NODE_H = 72, PORT_R = 6, GRID = 20;
const HISTORY_LIMIT = 50;

// ============================================================================
// Top-level screen — list view + opens FlowCanvas
// ============================================================================
function FlowBuilderScreen({ go }) {
  const [flows, setFlows]         = React.useState(null);
  const [activeFlowId, setActive] = React.useState(null);
  const [showCreate, setShowCreate] = React.useState(false);

  const loadFlows = React.useCallback(async () => {
    const r = await VoaisAPI.get("/api/flows");
    if (r.ok && r.data?.ok) setFlows(r.data.flows);
  }, []);
  React.useEffect(() => { loadFlows(); }, [loadFlows]);

  if (activeFlowId) {
    return <FlowCanvas flowId={activeFlowId} onBack={() => { setActive(null); loadFlows(); }}/>;
  }
  if (flows === null) return <DashboardSkeleton/>;

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--gap-grid)" }}>
      <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
        <Badge tone="blue" dot>{flows.length} flows</Badge>
        <div style={{ flex: 1 }}/>
        <Btn kind="primary" size="sm" icon={<I.plus size={13}/>} onClick={() => setShowCreate(true)}>New flow</Btn>
      </div>

      {flows.length === 0 && (
        <Card>
          <div style={{ padding: "40px 24px", textAlign: "center", maxWidth: 520, margin: "0 auto" }}>
            <div style={{ width: 64, height: 64, borderRadius: 16, margin: "0 auto 20px", display: "grid", placeItems: "center", background: "var(--accent-soft)", color: "var(--accent)" }}>
              <I.flow size={28}/>
            </div>
            <h2 style={{ fontSize: 22, fontWeight: 600, letterSpacing: "-0.02em", margin: "14px 0 10px" }}>No flows yet</h2>
            <p style={{ color: "var(--ink-3)", fontSize: 13.5, lineHeight: 1.6, margin: "0 0 20px" }}>
              Flows define the conversation logic your AI agents follow. Start from a template or build from scratch.
            </p>
            <Btn kind="primary" icon={<I.plus size={14}/>} onClick={() => setShowCreate(true)}>Create your first flow</Btn>
          </div>
        </Card>
      )}

      {flows.length > 0 && (
        <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: "var(--gap-grid)" }}>
          {flows.map(f => (
            <FlowListCard key={f.id} flow={f} onOpen={() => setActive(f.id)} onDelete={async () => {
              if (!confirm("Delete this flow?")) return;
              const r = await VoaisAPI.del("/api/flows/" + f.id);
              if (!r.ok) alert(r.data?.msg || "Delete failed.");
              loadFlows();
            }}/>
          ))}
        </div>
      )}

      {showCreate && <CreateFlowModal onClose={() => setShowCreate(false)} onCreated={(id) => { setShowCreate(false); setActive(id); }}/>}
    </div>
  );
}

// ── Flow list card ──────────────────────────────────────────────────────
function FlowListCard({ flow, onOpen, onDelete }) {
  const statusTone = flow.status === "published" ? "green" : flow.status === "draft" ? "gray" : "yellow";
  return (
    <Card padded={false}>
      <div style={{ padding: 16, cursor: "pointer" }} onClick={onOpen}>
        <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
          <I.flow size={18} style={{ color: "var(--accent)" }}/>
          <h3 style={{ margin: 0, fontSize: 15, fontWeight: 600, flex: 1 }}>{flow.name}</h3>
          <Badge tone={statusTone} dot>{flow.status}</Badge>
        </div>
        {flow.description && <div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 6, lineHeight: 1.5 }}>{flow.description}</div>}
        <div style={{ display: "flex", gap: 12, marginTop: 12, fontSize: 12, color: "var(--ink-3)" }}>
          <span>{flow.node_count || 0} nodes</span>
          <span>{flow.edge_count || 0} edges</span>
          {flow.agents_using > 0 && <span style={{ color: "var(--accent)" }}>{flow.agents_using} agent{flow.agents_using > 1 ? "s" : ""}</span>}
        </div>
      </div>
      <div style={{ padding: "0 16px 12px", display: "flex", gap: 8 }}>
        <Btn kind="primary" size="sm" style={{ flex: 1 }} onClick={onOpen}>Open editor</Btn>
        <Btn kind="ghost" size="sm" onClick={e => { e.stopPropagation(); onDelete(); }} title="Delete"><I.trash size={13}/></Btn>
      </div>
    </Card>
  );
}

// ── Create Flow Modal ───────────────────────────────────────────────────
function CreateFlowModal({ onClose, onCreated }) {
  const [name, setName]           = React.useState("");
  const [desc, setDesc]           = React.useState("");
  const [template, setTemplate]   = React.useState("");
  const [templates, setTemplates] = React.useState([]);
  const [submitting, setSub]      = React.useState(false);

  React.useEffect(() => {
    VoaisAPI.get("/api/flows/templates").then(r => {
      if (r.ok && r.data?.ok) setTemplates(r.data.templates);
    });
  }, []);

  const submit = async () => {
    if (!name.trim()) return;
    setSub(true);
    const r = await VoaisAPI.post("/api/flows", { name: name.trim(), description: desc.trim() || null, template: template || null });
    setSub(false);
    if (r.ok && r.data?.flowId) onCreated(r.data.flowId);
    else alert(r.data?.msg || "Failed to create flow.");
  };

  return (
    <Modal title="New flow" onClose={onClose} width={520}>
      <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
        <Field label="Flow name *">
          <input className="input" autoFocus placeholder="e.g. Lead Qualification" value={name} onChange={e => setName(e.target.value)}/>
        </Field>
        <Field label="Description">
          <textarea className="textarea" rows={2} placeholder="What this flow does..." value={desc} onChange={e => setDesc(e.target.value)}/>
        </Field>
        <Field label="Start from template" hint="Or leave blank for a minimal start/end flow">
          <select className="select" value={template} onChange={e => setTemplate(e.target.value)}>
            <option value="">Blank (start + greeting + end)</option>
            {templates.map(t => <option key={t.id} value={t.id}>{t.name} ({t.nodeCount} nodes)</option>)}
          </select>
        </Field>
        <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
          <Btn kind="ghost" onClick={onClose}>Cancel</Btn>
          <Btn kind="primary" onClick={submit} disabled={submitting || !name.trim()}>
            {submitting ? "Creating..." : "Create flow"}
          </Btn>
        </div>
      </div>
    </Modal>
  );
}


// ============================================================================
// FlowCanvas — the drag-drop editor
// ============================================================================
function FlowCanvas({ flowId, onBack }) {
  // ── Data state ────────────────────────────────────────────────────────
  const [flow, setFlow]       = React.useState(null);
  const [nodes, setNodesRaw]  = React.useState([]);
  const [edges, setEdgesRaw]  = React.useState([]);
  const [registry, setReg]    = React.useState(null);   // node-type registry from backend
  const [loading, setLoading] = React.useState(true);
  const [saving, setSaving]   = React.useState(false);
  const [validRes, setValid]  = React.useState(null);
  const [showTest, setShowTest] = React.useState(false);  // in-browser Flow Test panel
  const [highlightKey, setHighlightKey] = React.useState(null); // node highlighted while testing

  // ── UI state ──────────────────────────────────────────────────────────
  const [selected, setSelected]       = React.useState([]);   // array of node_keys for multi-select
  const [hoveredEdge, setHoveredEdge] = React.useState(null);
  const [connecting, setConnecting]   = React.useState(null); // dragging an edge
  const [rubberBand, setRubberBand]   = React.useState(null); // selection rectangle
  const [contextMenu, setContextMenu] = React.useState(null); // { x, y, nodeKey }
  const [search, setSearch]           = React.useState(null); // { open, query }
  const [advancedMode, setAdvancedMode] = React.useState(false);

  // ── Pan / zoom ────────────────────────────────────────────────────────
  const [pan, setPan]   = React.useState({ x: 0, y: 0 });
  const [zoom, setZoom] = React.useState(1);

  // ── Refs ──────────────────────────────────────────────────────────────
  const svgRef       = React.useRef(null);
  const wrapRef      = React.useRef(null);
  const saveTimer    = React.useRef(null);
  const panStart     = React.useRef(null);
  const dragNodeRef  = React.useRef(null);
  const rubberStart  = React.useRef(null);
  const clipboard    = React.useRef(null);   // copy buffer
  // Always-fresh mirrors of nodes/edges. setNodes/setEdges read the OTHER
  // collection from these refs (not from a stale render closure) so a rapid
  // node-change + edge-change in the same tick can't auto-save half the edit.
  const nodesRef     = React.useRef([]);
  const edgesRef     = React.useRef([]);

  // ── Undo / Redo stack ─────────────────────────────────────────────────
  // Each entry is a snapshot { nodes, edges }. Push on every meaningful
  // mutation. Pop on undo, push to redo stack. Limited to HISTORY_LIMIT.
  const history     = React.useRef([]);
  const historyIdx  = React.useRef(-1);

  const pushHistory = React.useCallback((n, e) => {
    // Truncate redo branch
    history.current = history.current.slice(0, historyIdx.current + 1);
    history.current.push({ nodes: deepClone(n), edges: deepClone(e) });
    if (history.current.length > HISTORY_LIMIT) history.current.shift();
    historyIdx.current = history.current.length - 1;
  }, []);

  // ── Save debounced ────────────────────────────────────────────────────
  const scheduleSave = React.useCallback((n, e) => {
    if (saveTimer.current) clearTimeout(saveTimer.current);
    saveTimer.current = setTimeout(async () => {
      setSaving(true);
      await VoaisAPI.put("/api/flows/" + flowId + "/canvas", { nodes: n, edges: e });
      setSaving(false);
    }, 1000);
  }, [flowId]);

  // setNodes / setEdges always push history + schedule save. They read the
  // sibling collection from refs (always current) instead of a render closure,
  // so a node change and an edge change in the same tick both persist.
  const setNodes = React.useCallback((updater) => {
    setNodesRaw(prev => {
      const next = typeof updater === "function" ? updater(prev) : updater;
      nodesRef.current = next;
      pushHistory(next, edgesRef.current);
      scheduleSave(next, edgesRef.current);
      return next;
    });
  }, [pushHistory, scheduleSave]);

  const setEdges = React.useCallback((updater) => {
    setEdgesRaw(prev => {
      const next = typeof updater === "function" ? updater(prev) : updater;
      edgesRef.current = next;
      pushHistory(nodesRef.current, next);
      scheduleSave(nodesRef.current, next);
      return next;
    });
  }, [pushHistory, scheduleSave]);

  // Update both atomically (avoid double save)
  const setBoth = React.useCallback((nextNodes, nextEdges) => {
    nodesRef.current = nextNodes;
    edgesRef.current = nextEdges;
    setNodesRaw(nextNodes);
    setEdgesRaw(nextEdges);
    pushHistory(nextNodes, nextEdges);
    scheduleSave(nextNodes, nextEdges);
  }, [pushHistory, scheduleSave]);

  // ── Load flow + registry ──────────────────────────────────────────────
  React.useEffect(() => {
    (async () => {
      setLoading(true);
      const [flowRes, regRes] = await Promise.all([
        VoaisAPI.get("/api/flows/" + flowId),
        VoaisAPI.get("/api/flows/node-types"),
      ]);
      if (flowRes.ok && flowRes.data?.ok) {
        setFlow(flowRes.data.flow);
        const n = flowRes.data.flow.nodes || [];
        const e = flowRes.data.flow.edges || [];
        setNodesRaw(n); setEdgesRaw(e);
        nodesRef.current = n; edgesRef.current = e;
        // Initial history snapshot
        history.current = [{ nodes: deepClone(n), edges: deepClone(e) }];
        historyIdx.current = 0;
      }
      if (regRes.ok && regRes.data?.ok) {
        setReg({ nodes: regRes.data.nodes, categories: regRes.data.categories });
      }
      setLoading(false);
    })();
  }, [flowId]);

  // ── Add a node from palette ───────────────────────────────────────────
  const addNode = React.useCallback((type, atSvg) => {
    if (!registry || !registry.nodes[type]) return;
    const meta = registry.nodes[type];
    const maxNum = nodes.reduce((m, n) => {
      const num = parseInt(String(n.node_key).replace(/\D/g, ""), 10);
      return num > m ? num : m;
    }, 0);
    const key = "n" + (maxNum + 1);
    const pos = atSvg || { x: 300, y: 200 };
    const newNode = {
      node_key: key, type,
      pos_x: snap(pos.x - NODE_W / 2),
      pos_y: snap(pos.y - NODE_H / 2),
      title: meta.label,
      subtitle: "",
      data: deepClone(meta.defaults || {}),
    };
    setNodes(prev => [...prev, newNode]);
    setSelected([key]);
  }, [nodes, registry, setNodes]);

  // ── Delete selected ───────────────────────────────────────────────────
  const deleteSelected = React.useCallback(() => {
    if (selected.length === 0) return;
    const keys = new Set(selected);
    const nextNodes = nodes.filter(n => !keys.has(n.node_key));
    const nextEdges = edges.filter(e => !keys.has(e.from_key) && !keys.has(e.to_key));
    setBoth(nextNodes, nextEdges);
    setSelected([]);
  }, [selected, nodes, edges, setBoth]);

  // ── Copy / Paste / Duplicate ──────────────────────────────────────────
  const doCopy = React.useCallback(() => {
    if (selected.length === 0) return;
    const keys = new Set(selected);
    const copiedNodes = nodes.filter(n => keys.has(n.node_key)).map(deepClone);
    const copiedEdges = edges.filter(e => keys.has(e.from_key) && keys.has(e.to_key)).map(deepClone);
    clipboard.current = { nodes: copiedNodes, edges: copiedEdges };
  }, [selected, nodes, edges]);

  const doPaste = React.useCallback(() => {
    if (!clipboard.current) return;
    const offset = 40;
    const keyMap = {};
    const maxNum = nodes.reduce((m, n) => {
      const num = parseInt(String(n.node_key).replace(/\D/g, ""), 10);
      return num > m ? num : m;
    }, 0);
    let next = maxNum;
    const newNodes = clipboard.current.nodes.map(n => {
      next += 1;
      const newKey = "n" + next;
      keyMap[n.node_key] = newKey;
      return { ...n, node_key: newKey, pos_x: n.pos_x + offset, pos_y: n.pos_y + offset };
    });
    // Only keep edges whose BOTH endpoints were part of the pasted selection
    // (remap them to the new keys). Filter BEFORE remapping so we test the
    // original keys; keeping an edge with one un-pasted endpoint would create a
    // dangling reference to a non-existent node.
    const newEdges = clipboard.current.edges
      .filter(e => keyMap[e.from_key] && keyMap[e.to_key])
      .map(e => ({ ...e, from_key: keyMap[e.from_key], to_key: keyMap[e.to_key] }));
    setBoth([...nodes, ...newNodes], [...edges, ...newEdges]);
    setSelected(newNodes.map(n => n.node_key));
  }, [nodes, edges, setBoth]);

  const doDuplicate = React.useCallback(() => {
    doCopy();
    setTimeout(doPaste, 0);
  }, [doCopy, doPaste]);

  // ── Undo / Redo ───────────────────────────────────────────────────────
  const undo = React.useCallback(() => {
    if (historyIdx.current <= 0) return;
    historyIdx.current -= 1;
    const snap = history.current[historyIdx.current];
    const sn = deepClone(snap.nodes), se = deepClone(snap.edges);
    nodesRef.current = sn; edgesRef.current = se;
    setNodesRaw(sn);
    setEdgesRaw(se);
    scheduleSave(sn, se);
  }, [scheduleSave]);

  const redo = React.useCallback(() => {
    if (historyIdx.current >= history.current.length - 1) return;
    historyIdx.current += 1;
    const snap = history.current[historyIdx.current];
    const sn = deepClone(snap.nodes), se = deepClone(snap.edges);
    nodesRef.current = sn; edgesRef.current = se;
    setNodesRaw(sn);
    setEdgesRaw(se);
    scheduleSave(sn, se);
  }, [scheduleSave]);

  // ── Select all ────────────────────────────────────────────────────────
  const selectAll = React.useCallback(() => {
    setSelected(nodes.map(n => n.node_key));
  }, [nodes]);

  // ── Auto-layout (top-down hierarchical) ──────────────────────────────
  // Find the start node, BFS outward, assign levels (y), pack siblings (x).
  const autoLayout = React.useCallback(() => {
    if (nodes.length === 0) return;
    const adj = {};
    for (const e of edges) {
      (adj[e.from_key] = adj[e.from_key] || []).push(e.to_key);
    }
    const startNode = nodes.find(n => n.type === "start") || nodes[0];
    const levels = {}; levels[startNode.node_key] = 0;
    const queue = [startNode.node_key];
    const visited = new Set([startNode.node_key]);
    while (queue.length) {
      const cur = queue.shift();
      for (const nxt of (adj[cur] || [])) {
        if (visited.has(nxt)) continue;
        levels[nxt] = (levels[cur] || 0) + 1;
        visited.add(nxt);
        queue.push(nxt);
      }
    }
    // Unreachable nodes get appended to the last level.
    const maxLvl = Math.max(0, ...Object.values(levels));
    for (const n of nodes) {
      if (!(n.node_key in levels)) levels[n.node_key] = maxLvl + 1;
    }
    // Group by level
    const byLevel = {};
    for (const n of nodes) {
      const l = levels[n.node_key];
      (byLevel[l] = byLevel[l] || []).push(n);
    }
    const xGap = NODE_W + 60, yGap = NODE_H + 80;
    const nextNodes = [];
    for (const [lvl, list] of Object.entries(byLevel)) {
      const y = 60 + Number(lvl) * yGap;
      const totalW = list.length * xGap;
      list.forEach((n, i) => {
        nextNodes.push({ ...n, pos_x: snap(80 + i * xGap - totalW / 2 + 400), pos_y: snap(y) });
      });
    }
    setNodes(nextNodes);
    setTimeout(fitView, 50);
  }, [nodes, edges, setNodes]);

  // ── Fit view ──────────────────────────────────────────────────────────
  const fitView = React.useCallback(() => {
    if (!nodes.length || !wrapRef.current) return;
    const xs = nodes.map(n => n.pos_x), ys = nodes.map(n => n.pos_y);
    const minX = Math.min(...xs), maxX = Math.max(...xs) + NODE_W;
    const minY = Math.min(...ys), maxY = Math.max(...ys) + NODE_H;
    const cw = wrapRef.current.clientWidth || 800;
    const ch = wrapRef.current.clientHeight || 500;
    const z = Math.min(cw / (maxX - minX + 100), ch / (maxY - minY + 100), 1.3);
    const newZoom = Math.max(0.3, Math.min(1.2, z));
    setZoom(newZoom);
    setPan({
      x: -minX * newZoom + (cw - (maxX - minX) * newZoom) / 2,
      y: -minY * newZoom + (ch - (maxY - minY) * newZoom) / 2,
    });
  }, [nodes]);

  // ── Publish / Validate ────────────────────────────────────────────────
  const handlePublish = async () => {
    setSaving(true);
    await VoaisAPI.put("/api/flows/" + flowId + "/canvas", { nodes, edges });
    const r = await VoaisAPI.post("/api/flows/" + flowId + "/publish");
    setSaving(false);
    if (r.ok) alert("Published v" + r.data.version);
    else alert(r.data?.msg || "Publish failed.");
  };
  const handleValidate = async () => {
    await VoaisAPI.put("/api/flows/" + flowId + "/canvas", { nodes, edges });
    const r = await VoaisAPI.post("/api/flows/" + flowId + "/validate");
    if (r.ok) setValid(r.data);
  };

  // ── Keyboard shortcuts ────────────────────────────────────────────────
  React.useEffect(() => {
    const onKey = (e) => {
      // Don't fire shortcuts when typing in an input/textarea
      const tag = (e.target?.tagName || "").toLowerCase();
      if (tag === "input" || tag === "textarea" || tag === "select" || e.target?.isContentEditable) return;

      const meta = e.metaKey || e.ctrlKey;
      if (meta && e.key === "z" && !e.shiftKey)        { e.preventDefault(); undo(); }
      else if (meta && (e.key === "y" || (e.key === "z" && e.shiftKey))) { e.preventDefault(); redo(); }
      else if (meta && e.key === "c")                  { e.preventDefault(); doCopy(); }
      else if (meta && e.key === "v")                  { e.preventDefault(); doPaste(); }
      else if (meta && e.key === "d")                  { e.preventDefault(); doDuplicate(); }
      else if (meta && e.key === "a")                  { e.preventDefault(); selectAll(); }
      else if (meta && e.key === "f")                  { e.preventDefault(); setSearch({ query: "" }); }
      else if (e.key === "Delete" || e.key === "Backspace") { if (selected.length) { e.preventDefault(); deleteSelected(); } }
      else if (e.key === "Escape")                     { setSearch(null); setContextMenu(null); setSelected([]); }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [undo, redo, doCopy, doPaste, doDuplicate, selectAll, deleteSelected, selected]);

  // ── Node drag handler ─────────────────────────────────────────────────
  const handleNodeMouseDown = (e, nodeKey) => {
    if (e.button !== 0) return;
    e.stopPropagation();
    const svg = svgRef.current;
    const pt = svgPoint(svg, e);
    const isShift = e.shiftKey;
    // Selection logic
    let nextSel;
    if (isShift) {
      nextSel = selected.includes(nodeKey) ? selected.filter(k => k !== nodeKey) : [...selected, nodeKey];
    } else if (!selected.includes(nodeKey)) {
      nextSel = [nodeKey];
    } else {
      nextSel = selected;
    }
    setSelected(nextSel);

    // Capture starting positions of all selected (for group move)
    const startPositions = {};
    for (const n of nodes) {
      if (nextSel.includes(n.node_key)) startPositions[n.node_key] = { x: n.pos_x, y: n.pos_y };
    }
    dragNodeRef.current = { anchor: nodeKey, startMouse: pt, startPositions, moved: false };

    const onMove = (ev) => {
      const mp = svgPoint(svg, ev);
      const dx = mp.x - dragNodeRef.current.startMouse.x;
      const dy = mp.y - dragNodeRef.current.startMouse.y;
      if (Math.abs(dx) > 1 || Math.abs(dy) > 1) dragNodeRef.current.moved = true;
      const sp = dragNodeRef.current.startPositions;
      setNodesRaw(prev => prev.map(n => sp[n.node_key]
        ? { ...n, pos_x: snap(sp[n.node_key].x + dx), pos_y: snap(sp[n.node_key].y + dy) }
        : n
      ));
    };
    const onUp = () => {
      document.removeEventListener("mousemove", onMove);
      document.removeEventListener("mouseup", onUp);
      if (dragNodeRef.current?.moved) {
        // Push history + save after the drag settles
        setNodesRaw(prev => { nodesRef.current = prev; pushHistory(prev, edgesRef.current); scheduleSave(prev, edgesRef.current); return prev; });
      }
      dragNodeRef.current = null;
    };
    document.addEventListener("mousemove", onMove);
    document.addEventListener("mouseup", onUp);
  };

  // ── Port drag (creating an edge) ──────────────────────────────────────
  const handlePortMouseDown = (e, fromKey, outIndex) => {
    e.stopPropagation();
    const svg = svgRef.current;
    const pt = svgPoint(svg, e);
    setConnecting({ from_key: fromKey, out_index: outIndex, mx: pt.x, my: pt.y });

    const onMove = (ev) => {
      const mp = svgPoint(svg, ev);
      setConnecting(prev => prev ? { ...prev, mx: mp.x, my: mp.y } : null);
    };
    const onUp = (ev) => {
      document.removeEventListener("mousemove", onMove);
      document.removeEventListener("mouseup", onUp);
      const mp = svgPoint(svg, ev);
      // Find target node whose input port is near mouse
      const target = nodes.find(n => {
        const meta = registry?.nodes[n.type];
        if (!meta?.hasIn) return false;
        const px = n.pos_x + NODE_W / 2, py = n.pos_y;
        return Math.abs(mp.x - px) < 24 && Math.abs(mp.y - py) < 24;
      });
      if (target && target.node_key !== fromKey) {
        const meta = registry?.nodes[nodes.find(n => n.node_key === fromKey)?.type];
        let label = null;
        if (meta?.multiOut) {
          const fromNode = nodes.find(n => n.node_key === fromKey);
          const branches = fromNode.data?.branches || fromNode.data?.digits || [];
          label = branches[outIndex] || null;
        }
        const exists = edges.some(e =>
          e.from_key === fromKey && e.to_key === target.node_key &&
          (e.label || null) === (label || null)
        );
        if (!exists) {
          setEdges(prev => [...prev, { from_key: fromKey, to_key: target.node_key, label, condition: null }]);
        }
      }
      setConnecting(null);
    };
    document.addEventListener("mousemove", onMove);
    document.addEventListener("mouseup", onUp);
  };

  // ── Canvas pan + rubber-band selection ────────────────────────────────
  const handleCanvasMouseDown = (e) => {
    if (e.target !== svgRef.current && !e.target.classList.contains("flow-bg")) return;
    setContextMenu(null);
    const isShift = e.shiftKey;
    const isMiddle = e.button === 1;
    const svg = svgRef.current;
    const pt = svgPoint(svg, e);

    // Middle mouse = pan; Left = rubber band (with shift = add to selection); plain left clears selection
    if (isMiddle || e.button === 2) {
      // Pan
      panStart.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
      const onMove = (ev) => setPan({ x: ev.clientX - panStart.current.x, y: ev.clientY - panStart.current.y });
      const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
      document.addEventListener("mousemove", onMove);
      document.addEventListener("mouseup", onUp);
      return;
    }

    // Left button — rubber band
    if (!isShift) setSelected([]);
    rubberStart.current = pt;
    setRubberBand({ x1: pt.x, y1: pt.y, x2: pt.x, y2: pt.y });
    const onMove = (ev) => {
      const mp = svgPoint(svg, ev);
      setRubberBand({ x1: rubberStart.current.x, y1: rubberStart.current.y, x2: mp.x, y2: mp.y });
    };
    const onUp = (ev) => {
      document.removeEventListener("mousemove", onMove);
      document.removeEventListener("mouseup", onUp);
      const mp = svgPoint(svg, ev);
      const x1 = Math.min(rubberStart.current.x, mp.x), x2 = Math.max(rubberStart.current.x, mp.x);
      const y1 = Math.min(rubberStart.current.y, mp.y), y2 = Math.max(rubberStart.current.y, mp.y);
      if (Math.abs(x2 - x1) > 5 || Math.abs(y2 - y1) > 5) {
        const within = nodes.filter(n =>
          n.pos_x + NODE_W >= x1 && n.pos_x <= x2 &&
          n.pos_y + NODE_H >= y1 && n.pos_y <= y2
        ).map(n => n.node_key);
        setSelected(isShift ? Array.from(new Set([...selected, ...within])) : within);
      }
      setRubberBand(null);
      rubberStart.current = null;
    };
    document.addEventListener("mousemove", onMove);
    document.addEventListener("mouseup", onUp);
  };

  const handleWheel = (e) => {
    if (!e.shiftKey && !e.ctrlKey && !e.metaKey) return;
    e.preventDefault();
    const delta = e.deltaY > 0 ? -0.08 : 0.08;
    setZoom(prev => Math.min(2, Math.max(0.3, prev + delta)));
  };

  // ── Drop from palette ─────────────────────────────────────────────────
  const handleDrop = (e) => {
    e.preventDefault();
    const type = e.dataTransfer.getData("application/flow-node");
    if (!type) return;
    const pt = svgPoint(svgRef.current, e);
    addNode(type, pt);
  };
  const handleDragOver = (e) => { if (e.dataTransfer.types.includes("application/flow-node")) e.preventDefault(); };

  // ── Right-click context menu ──────────────────────────────────────────
  const handleNodeContextMenu = (e, nodeKey) => {
    e.preventDefault();
    e.stopPropagation();
    if (!selected.includes(nodeKey)) setSelected([nodeKey]);
    setContextMenu({ x: e.clientX, y: e.clientY, nodeKey });
  };

  // ── Render ────────────────────────────────────────────────────────────
  if (loading) return <DashboardSkeleton/>;

  const selectedNode = selected.length === 1 ? nodes.find(n => n.node_key === selected[0]) : null;
  const filteredNodes = search?.query
    ? nodes.filter(n => (n.title || "").toLowerCase().includes(search.query.toLowerCase()) ||
                         n.type.toLowerCase().includes(search.query.toLowerCase()))
    : [];

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 0, height: "calc(100vh - 140px)" }}>
      {/* ─── Toolbar ─── */}
      <FlowToolbar
        flow={flow}
        saving={saving}
        canUndo={historyIdx.current > 0}
        canRedo={historyIdx.current < history.current.length - 1}
        selectedCount={selected.length}
        onBack={onBack}
        onUndo={undo}
        onRedo={redo}
        onValidate={handleValidate}
        onAutoLayout={autoLayout}
        onFitView={fitView}
        onPublish={handlePublish}
        onTest={() => setShowTest(true)}
        onSearch={() => setSearch({ query: "" })}
        zoom={zoom}
      />

      {showTest && (
        <FlowTestPanel
          flow={flow}
          nodes={nodes}
          edges={edges}
          registry={registry}
          onHighlight={setHighlightKey}
          onClose={() => { setShowTest(false); setHighlightKey(null); }}
        />
      )}

      {/* ─── Validation banner ─── */}
      {validRes && <ValidationBanner res={validRes} onClose={() => setValid(null)}/>}

      <div style={{ display: "flex", flex: 1, gap: 0, minHeight: 0, position: "relative" }}>
        {/* ─── Palette (left) ─── */}
        {registry && <NodePalette registry={registry} onAddCenter={(type) => {
          const pt = wrapRef.current
            ? svgPoint(svgRef.current, { clientX: wrapRef.current.getBoundingClientRect().left + wrapRef.current.clientWidth / 2,
                                          clientY: wrapRef.current.getBoundingClientRect().top + wrapRef.current.clientHeight / 2 })
            : { x: 300, y: 200 };
          addNode(type, pt);
        }}/>}

        {/* ─── Canvas ─── */}
        <div ref={wrapRef}
          style={{ flex: 1, position: "relative", overflow: "hidden", background: "var(--surface-2)", borderRadius: 8 }}
          onWheel={handleWheel}
          onDrop={handleDrop}
          onDragOver={handleDragOver}
          onContextMenu={(e) => { if (!e.target.closest('g[data-node-key]')) { e.preventDefault(); setContextMenu(null); } }}
        >
          <svg ref={svgRef} width="100%" height="100%"
            style={{ cursor: connecting ? "crosshair" : (rubberBand ? "default" : "grab"), userSelect: "none" }}
            onMouseDown={handleCanvasMouseDown}>

            <defs>
              <pattern id="flowgrid" width={GRID * zoom} height={GRID * zoom} patternUnits="userSpaceOnUse"
                       x={pan.x % (GRID * zoom)} y={pan.y % (GRID * zoom)}>
                <circle cx={GRID * zoom / 2} cy={GRID * zoom / 2} r={0.8} fill="var(--ink-3)" opacity="0.2"/>
              </pattern>
              <marker id="flowArrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
                <path d="M2 1L8 5L2 9" fill="none" stroke="context-stroke" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
              </marker>
            </defs>
            <rect className="flow-bg" width="100%" height="100%" fill="url(#flowgrid)"/>

            <g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
              {/* Edges */}
              {edges.map((e, i) => (
                <FlowEdge key={i} edge={e} index={i} nodes={nodes} registry={registry}
                  isHovered={hoveredEdge === i}
                  onMouseEnter={() => setHoveredEdge(i)}
                  onMouseLeave={() => setHoveredEdge(null)}
                  onDelete={() => setEdges(prev => prev.filter((_, j) => j !== i))}/>
              ))}

              {/* Connecting line (dragging) */}
              {connecting && (() => {
                const from = nodes.find(n => n.node_key === connecting.from_key);
                if (!from) return null;
                const meta = registry?.nodes[from.type];
                const branches = meta?.multiOut ? (from.data?.branches || from.data?.digits || [from.type]) : [null];
                const numOut = Math.max(1, branches.length);
                const portX = from.pos_x + NODE_W * ((connecting.out_index + 1) / (numOut + 1));
                const portY = from.pos_y + NODE_H;
                return <line x1={portX} y1={portY} x2={connecting.mx} y2={connecting.my}
                  stroke="var(--accent)" strokeWidth="2" strokeDasharray="6 4" opacity="0.8"/>;
              })()}

              {/* Highlight rectangle for search results */}
              {filteredNodes.map(n => (
                <rect key={"hl-" + n.node_key} x={n.pos_x - 6} y={n.pos_y - 6}
                  width={NODE_W + 12} height={NODE_H + 12} rx={14} fill="none"
                  stroke="#FFB000" strokeWidth="2.5" opacity="0.8"/>
              ))}

              {/* Nodes */}
              {nodes.map(n => (
                <FlowNode key={n.node_key} node={n} registry={registry}
                  isSelected={selected.includes(n.node_key)}
                  highlighted={highlightKey === n.node_key}
                  onMouseDown={(e) => handleNodeMouseDown(e, n.node_key)}
                  onContextMenu={(e) => handleNodeContextMenu(e, n.node_key)}
                  onPortDown={(e, idx) => handlePortMouseDown(e, n.node_key, idx)}/>
              ))}

              {/* Rubber band */}
              {rubberBand && (
                <rect x={Math.min(rubberBand.x1, rubberBand.x2)} y={Math.min(rubberBand.y1, rubberBand.y2)}
                  width={Math.abs(rubberBand.x2 - rubberBand.x1)} height={Math.abs(rubberBand.y2 - rubberBand.y1)}
                  fill="var(--accent)" fillOpacity="0.08" stroke="var(--accent)" strokeWidth="1" strokeDasharray="4 3"/>
              )}
            </g>
          </svg>

          {/* Zoom + controls */}
          <FlowZoomControls zoom={zoom} setZoom={setZoom} onFit={fitView}/>

          {/* Minimap */}
          {nodes.length > 0 && <Minimap nodes={nodes} pan={pan} zoom={zoom} wrap={wrapRef.current}
            registry={registry} onJump={(p) => setPan(p)}/>}

          {/* Search */}
          {search && <SearchPanel
            query={search.query}
            onChange={(q) => setSearch({ query: q })}
            results={filteredNodes}
            onJump={(n) => {
              setSelected([n.node_key]);
              // Center on node
              if (wrapRef.current) {
                const cw = wrapRef.current.clientWidth, ch = wrapRef.current.clientHeight;
                setPan({ x: -n.pos_x * zoom + cw / 2 - (NODE_W * zoom) / 2, y: -n.pos_y * zoom + ch / 2 - (NODE_H * zoom) / 2 });
              }
            }}
            onClose={() => setSearch(null)}/>}
        </div>

        {/* ─── Inspector (right) ─── */}
        {selectedNode && registry && (
          <NodeInspector
            node={selectedNode}
            registry={registry}
            edges={edges}
            allNodes={nodes}
            advancedMode={advancedMode}
            onToggleAdvanced={() => setAdvancedMode(v => !v)}
            onChange={(updates) => {
              setNodes(prev => prev.map(n => n.node_key === selectedNode.node_key ? { ...n, ...updates } : n));
            }}
            onChangeData={(updates) => {
              setNodes(prev => prev.map(n => n.node_key === selectedNode.node_key
                ? { ...n, data: { ...(n.data || {}), ...updates } } : n));
            }}
            onDelete={() => { setSelected([]); deleteSelected(); }}
            onClose={() => setSelected([])}/>
        )}

        {/* ─── Context menu ─── */}
        {contextMenu && <NodeContextMenu
          x={contextMenu.x} y={contextMenu.y}
          onClose={() => setContextMenu(null)}
          onDuplicate={() => { doDuplicate(); setContextMenu(null); }}
          onCopy={() => { doCopy(); setContextMenu(null); }}
          onDelete={() => { deleteSelected(); setContextMenu(null); }}
          selectedCount={selected.length}/>}
      </div>
    </div>
  );
}

// ── Helpers ─────────────────────────────────────────────────────────────
function deepClone(v) { return JSON.parse(JSON.stringify(v)); }
function snap(v) { return Math.round(v / GRID) * GRID; }

// Get SVG coords given a mouse event, accounting for pan/zoom transform.
function svgPoint(svg, evt) {
  if (!svg) return { x: 0, y: 0 };
  const g = svg.querySelector("g[transform]");
  if (!g) {
    const rect = svg.getBoundingClientRect();
    return { x: evt.clientX - rect.left, y: evt.clientY - rect.top };
  }
  const ctm = g.getScreenCTM();
  if (!ctm) {
    const rect = svg.getBoundingClientRect();
    return { x: evt.clientX - rect.left, y: evt.clientY - rect.top };
  }
  return {
    x: (evt.clientX - ctm.e) / ctm.a,
    y: (evt.clientY - ctm.f) / ctm.d,
  };
}


// ============================================================================
// SUB-COMPONENTS
// ============================================================================

// ── Toolbar (top) ───────────────────────────────────────────────────────
function FlowToolbar({ flow, saving, canUndo, canRedo, selectedCount,
                       onBack, onUndo, onRedo, onValidate, onAutoLayout,
                       onFitView, onPublish, onTest, onSearch, zoom }) {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 6, padding: "8px 0", flexWrap: "wrap" }}>
      <Btn kind="ghost" size="sm" icon={<I.arrow_left size={13}/>} onClick={onBack}>Flows</Btn>
      <div style={{ width: 1, height: 20, background: "var(--line)", margin: "0 4px" }}/>
      <span style={{ fontSize: 15, fontWeight: 600 }}>{flow?.name || "Flow"}</span>
      <Badge tone={flow?.status === "published" ? "green" : "gray"} dot>{flow?.status}</Badge>
      {selectedCount > 1 && <Badge tone="blue">{selectedCount} selected</Badge>}
      <div style={{ flex: 1 }}/>
      {saving && <span style={{ fontSize: 12, color: "var(--ink-3)" }}>Saving…</span>}
      <Btn kind="ghost" size="sm" icon={<I.arrow_left size={13}/>} onClick={onUndo} disabled={!canUndo} title="Undo (Ctrl+Z)"/>
      <Btn kind="ghost" size="sm" icon={<I.arrow_right size={13}/>} onClick={onRedo} disabled={!canRedo} title="Redo (Ctrl+Y)"/>
      <div style={{ width: 1, height: 20, background: "var(--line)", margin: "0 4px" }}/>
      <Btn kind="ghost" size="sm" icon={<I.search size={13}/>} onClick={onSearch} title="Search (Ctrl+F)"/>
      <Btn kind="ghost" size="sm" icon={<I.grid size={13}/>} onClick={onAutoLayout} title="Auto-layout">Layout</Btn>
      <Btn kind="ghost" size="sm" onClick={onFitView} title="Fit view">Fit</Btn>
      <Btn kind="ghost" size="sm" icon={<I.check size={13}/>} onClick={onValidate}>Validate</Btn>
      <Btn kind="ghost" size="sm" icon={<I.phone size={13}/>} onClick={onTest} title="Test this flow in your browser with your mic">Test</Btn>
      <Btn kind="primary" size="sm" icon={<I.zap size={13}/>} onClick={onPublish}>Publish</Btn>
    </div>
  );
}

function ValidationBanner({ res, onClose }) {
  return (
    <div style={{
      padding: "10px 12px",
      background: res.valid ? "rgba(29,158,117,0.10)" : "rgba(216,90,48,0.10)",
      border: "1px solid " + (res.valid ? "rgba(29,158,117,0.4)" : "rgba(216,90,48,0.4)"),
      borderRadius: 8, fontSize: 12.5, marginBottom: 8,
    }}>
      <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <I.check size={14} style={{ color: res.valid ? "var(--ok)" : "var(--err)" }}/>
        <div style={{ fontWeight: 600, flex: 1 }}>{res.valid ? "Flow is valid" : "Validation issues found"}</div>
        <button className="topbar-icon-btn" onClick={onClose}><I.x size={12}/></button>
      </div>
      {(res.errors || []).map((e, i) => <div key={"e"+i} style={{ color: "#f87171", marginTop: 4 }}>• {e}</div>)}
      {(res.warnings || []).map((w, i) => <div key={"w"+i} style={{ color: "#facc15", marginTop: 4 }}>• {w}</div>)}
    </div>
  );
}

// ── Node Palette (left) ─────────────────────────────────────────────────
function NodePalette({ registry, onAddCenter }) {
  const [collapsed, setCollapsed] = React.useState(false);
  const grouped = {};
  for (const [key, meta] of Object.entries(registry.nodes)) {
    (grouped[meta.category] = grouped[meta.category] || []).push({ ...meta, key });
  }
  const categories = registry.categories.sort((a, b) => a.order - b.order);

  if (collapsed) {
    return (
      <div style={{ width: 36, borderRight: "1px solid var(--line)", background: "var(--surface)", flexShrink: 0,
                    display: "flex", flexDirection: "column", alignItems: "center", padding: "8px 0" }}>
        <button className="topbar-icon-btn" onClick={() => setCollapsed(false)} title="Expand palette">
          <I.chevR size={14}/>
        </button>
      </div>
    );
  }

  return (
    <div style={{ width: 220, borderRight: "1px solid var(--line)", padding: "10px 8px 12px", overflowY: "auto",
                  background: "var(--surface)", flexShrink: 0 }}>
      <div style={{ display: "flex", alignItems: "center", marginBottom: 10, padding: "0 4px" }}>
        <div style={{ fontSize: 11, fontWeight: 600, color: "var(--ink-3)", flex: 1, letterSpacing: "0.04em" }}>NODE LIBRARY</div>
        <button className="topbar-icon-btn" onClick={() => setCollapsed(true)} title="Collapse">
          <I.chevL size={13}/>
        </button>
      </div>
      <div style={{ fontSize: 10.5, color: "var(--ink-3)", padding: "0 4px 8px", lineHeight: 1.5 }}>
        Drag onto canvas, or click to add at center.
      </div>
      {categories.map(cat => grouped[cat.key] && (
        <div key={cat.key} style={{ marginBottom: 12 }}>
          <div style={{ fontSize: 10.5, fontWeight: 600, color: "var(--ink-3)", padding: "4px 6px", letterSpacing: "0.06em" }}>
            {cat.label.toUpperCase()}
          </div>
          {grouped[cat.key].map(meta => (
            <PaletteItem key={meta.key} meta={meta} onClick={() => onAddCenter(meta.key)}/>
          ))}
        </div>
      ))}
    </div>
  );
}

function PaletteItem({ meta, onClick }) {
  const Icon = I[meta.icon] || I.flow;
  const onDragStart = (e) => {
    e.dataTransfer.setData("application/flow-node", meta.key);
    e.dataTransfer.effectAllowed = "copy";
  };
  return (
    <div draggable onDragStart={onDragStart} onClick={onClick} title={meta.description}
      style={{
        display: "flex", alignItems: "center", gap: 8, padding: "7px 8px", marginBottom: 3,
        borderRadius: 7, cursor: "grab", fontSize: 12, background: "var(--surface-2)",
        border: "1px solid transparent", transition: "border-color 0.12s",
      }}
      onMouseEnter={(e) => e.currentTarget.style.borderColor = meta.color}
      onMouseLeave={(e) => e.currentTarget.style.borderColor = "transparent"}
    >
      <div style={{ width: 20, height: 20, borderRadius: 5, background: meta.color + "22",
                    display: "grid", placeItems: "center", color: meta.color, flexShrink: 0 }}>
        <Icon size={12}/>
      </div>
      <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
        {meta.label}
      </span>
    </div>
  );
}

// ── Single node (SVG) ───────────────────────────────────────────────────
function FlowNode({ node, registry, isSelected, highlighted, onMouseDown, onContextMenu, onPortDown }) {
  const meta = registry?.nodes[node.type];
  if (!meta) {
    // Unknown node type — render a warning placeholder so the user can fix it.
    return (
      <g transform={`translate(${node.pos_x}, ${node.pos_y})`}
        onMouseDown={onMouseDown} onContextMenu={onContextMenu} style={{ cursor: "grab" }}>
        <rect x={0} y={0} width={NODE_W} height={NODE_H} rx={10}
          fill="rgba(216,90,48,0.1)" stroke="var(--err)" strokeWidth={1} strokeDasharray="4 3"/>
        <text x={NODE_W/2} y={NODE_H/2 + 4} fontSize="12" fill="var(--err)" textAnchor="middle">
          Unknown: {node.type}
        </text>
      </g>
    );
  }

  // Multi-out nodes (intent / branch / dtmf / api_call / loop / datetime_check)
  // emit one output port per branch label. Single-out nodes have just one.
  const branches = meta.multiOut
    ? (node.data?.branches || node.data?.digits || node.data?.conditions?.map(c => c.label) || [meta.label])
    : (meta.hasOut ? [null] : []);
  const numOut = Math.max(1, branches.length);

  return (
    <g transform={`translate(${node.pos_x}, ${node.pos_y})`} data-node-key={node.node_key}
      onMouseDown={onMouseDown} onContextMenu={onContextMenu} style={{ cursor: "grab" }}>
      {/* Selection ring */}
      {isSelected && (
        <rect x={-4} y={-4} width={NODE_W + 8} height={NODE_H + 8} rx={14} fill="none"
          stroke="var(--accent)" strokeWidth="2" strokeDasharray="6 3" opacity="0.7"/>
      )}
      {/* Active-node ring while testing the flow */}
      {highlighted && (
        <rect x={-6} y={-6} width={NODE_W + 12} height={NODE_H + 12} rx={16} fill="none"
          stroke="var(--ok)" strokeWidth="3" opacity="0.95">
          <animate attributeName="opacity" values="0.95;0.35;0.95" dur="1.1s" repeatCount="indefinite"/>
        </rect>
      )}
      {/* Body */}
      <rect x={0} y={0} width={NODE_W} height={NODE_H} rx={10}
        fill="var(--surface)" stroke={meta.color} strokeWidth={isSelected ? 2 : 1.2} opacity="0.98"/>
      {/* Color accent bar */}
      <rect x={0} y={0} width={6} height={NODE_H} rx={3} fill={meta.color} opacity="0.9"/>
      {/* Icon background */}
      <rect x={14} y={NODE_H/2 - 12} width={24} height={24} rx={6}
        fill={meta.color} fillOpacity={0.15}/>
      {/* Title */}
      <text x={48} y={NODE_H/2 - 4} fontSize="13" fontWeight="600" fill="var(--ink-1)"
        fontFamily="var(--font-sans, sans-serif)">
        {(node.title || meta.label).slice(0, 22)}
      </text>
      {/* Subtitle */}
      <text x={48} y={NODE_H/2 + 14} fontSize="11" fill="var(--ink-3)"
        fontFamily="var(--font-sans, sans-serif)">
        {(node.subtitle || meta.key).slice(0, 28)}
      </text>
      {/* Input port */}
      {meta.hasIn && (
        <circle cx={NODE_W / 2} cy={0} r={PORT_R}
          fill="var(--surface)" stroke={meta.color} strokeWidth="2"/>
      )}
      {/* Output port(s) */}
      {meta.hasOut && branches.map((label, idx) => {
        const portX = NODE_W * ((idx + 1) / (numOut + 1));
        return (
          <g key={idx}>
            <circle cx={portX} cy={NODE_H} r={PORT_R}
              fill="var(--surface)" stroke={meta.color} strokeWidth="2"
              style={{ cursor: "crosshair" }}
              onMouseDown={(e) => { e.stopPropagation(); onPortDown(e, idx); }}/>
            {label && (
              <text x={portX} y={NODE_H + PORT_R + 12} fontSize="9.5" fill="var(--ink-3)"
                textAnchor="middle" fontFamily="var(--font-sans, sans-serif)">
                {String(label).slice(0, 12)}
              </text>
            )}
          </g>
        );
      })}
    </g>
  );
}

// ── Edge (bezier path) ──────────────────────────────────────────────────
function FlowEdge({ edge, index, nodes, registry, isHovered, onMouseEnter, onMouseLeave, onDelete }) {
  const fromNode = nodes.find(n => n.node_key === edge.from_key);
  const toNode   = nodes.find(n => n.node_key === edge.to_key);
  if (!fromNode || !toNode) return null;
  const fromMeta = registry?.nodes[fromNode.type];

  // Figure out which output port this edge starts from (by label match for multiOut)
  let outIdx = 0;
  let numOut = 1;
  if (fromMeta?.multiOut) {
    const branches = fromNode.data?.branches || fromNode.data?.digits || fromNode.data?.conditions?.map(c => c.label) || [];
    numOut = Math.max(1, branches.length);
    const i = branches.findIndex(b => b === edge.label);
    if (i >= 0) outIdx = i;
  }
  const x1 = fromNode.pos_x + NODE_W * ((outIdx + 1) / (numOut + 1));
  const y1 = fromNode.pos_y + NODE_H;
  const x2 = toNode.pos_x + NODE_W / 2;
  const y2 = toNode.pos_y;
  const dy = Math.max(40, Math.abs(y2 - y1) * 0.5);
  const cy1 = y1 + dy, cy2 = y2 - dy;
  const midX = (x1 + x2) / 2, midY = (y1 + y2) / 2;

  return (
    <g onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
      {/* Invisible wider hit-area for easier hover/click */}
      <path d={`M${x1} ${y1} C${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`}
        fill="none" stroke="transparent" strokeWidth="12"
        style={{ cursor: "pointer" }}/>
      {/* Visible edge */}
      <path d={`M${x1} ${y1} C${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`}
        fill="none"
        stroke={isHovered ? "var(--accent)" : "var(--ink-3)"}
        strokeWidth={isHovered ? "2" : "1.5"}
        opacity={isHovered ? "0.9" : "0.55"}
        markerEnd="url(#flowArrow)"
        style={{ pointerEvents: "none", transition: "stroke 0.12s, stroke-width 0.12s" }}/>
      {/* Label */}
      {edge.label && (
        <g transform={`translate(${midX}, ${midY - 8})`}>
          <rect x={-30} y={-9} width={60} height={18} rx={4}
            fill="var(--surface)" stroke="var(--line)" strokeWidth="1"/>
          <text x={0} y={4} fontSize="10.5" fill="var(--ink-2)" textAnchor="middle"
            fontFamily="var(--font-sans, sans-serif)" fontWeight="500">
            {String(edge.label).slice(0, 14)}
          </text>
        </g>
      )}
      {/* Delete on hover */}
      {isHovered && (
        <g transform={`translate(${midX + 28}, ${midY - 8})`} style={{ cursor: "pointer" }}
          onClick={(e) => { e.stopPropagation(); onDelete(); }}>
          <circle cx={0} cy={0} r={10} fill="var(--err)" opacity="0.95"/>
          <text x={0} y={4} fontSize="14" fill="white" textAnchor="middle" fontWeight="700"
            style={{ pointerEvents: "none" }}>×</text>
        </g>
      )}
    </g>
  );
}

// ── Zoom controls (bottom-left) ─────────────────────────────────────────
function FlowZoomControls({ zoom, setZoom, onFit }) {
  return (
    <div style={{ position: "absolute", bottom: 12, left: 12,
                  display: "flex", alignItems: "center", gap: 4,
                  background: "var(--surface)", padding: "4px 6px", borderRadius: 8,
                  border: "1px solid var(--line)", fontSize: 11 }}>
      <button className="topbar-icon-btn" onClick={() => setZoom(z => Math.max(0.3, z - 0.1))} title="Zoom out">−</button>
      <span style={{ minWidth: 38, textAlign: "center", color: "var(--ink-3)" }}>{Math.round(zoom * 100)}%</span>
      <button className="topbar-icon-btn" onClick={() => setZoom(z => Math.min(2, z + 0.1))} title="Zoom in">+</button>
      <button className="topbar-icon-btn" onClick={onFit} title="Fit"><I.expand size={12}/></button>
    </div>
  );
}

// ── Minimap (bottom-right) ──────────────────────────────────────────────
function Minimap({ nodes, pan, zoom, wrap, registry, onJump }) {
  const W = 160, H = 110;
  if (!nodes.length) return null;
  const xs = nodes.map(n => n.pos_x), ys = nodes.map(n => n.pos_y);
  const minX = Math.min(...xs) - 50, maxX = Math.max(...xs) + NODE_W + 50;
  const minY = Math.min(...ys) - 50, maxY = Math.max(...ys) + NODE_H + 50;
  const w = maxX - minX, h = maxY - minY;
  const scale = Math.min(W / w, H / h);

  const handleClick = (e) => {
    if (!wrap) return;
    const rect = e.currentTarget.getBoundingClientRect();
    const mx = e.clientX - rect.left;
    const my = e.clientY - rect.top;
    // Center the canvas on this point
    const worldX = minX + mx / scale;
    const worldY = minY + my / scale;
    onJump({
      x: -worldX * zoom + wrap.clientWidth / 2,
      y: -worldY * zoom + wrap.clientHeight / 2,
    });
  };

  // Viewport rectangle (where we're currently looking)
  const viewportX = -pan.x / zoom;
  const viewportY = -pan.y / zoom;
  const viewportW = wrap ? wrap.clientWidth / zoom : 600;
  const viewportH = wrap ? wrap.clientHeight / zoom : 400;

  return (
    <div style={{ position: "absolute", bottom: 12, right: 12,
                  background: "var(--surface)", padding: 4, borderRadius: 8,
                  border: "1px solid var(--line)", cursor: "pointer" }}
         onClick={handleClick}>
      <svg width={W} height={H} style={{ display: "block" }}>
        <rect width={W} height={H} fill="var(--surface-2)" rx={4}/>
        {nodes.map(n => {
          const meta = registry?.nodes[n.type];
          return (
            <rect key={n.node_key}
              x={(n.pos_x - minX) * scale} y={(n.pos_y - minY) * scale}
              width={Math.max(2, NODE_W * scale)} height={Math.max(2, NODE_H * scale)}
              rx={1.5} fill={meta?.color || "#888"} opacity={0.8}/>
          );
        })}
        <rect x={Math.max(0, (viewportX - minX) * scale)}
              y={Math.max(0, (viewportY - minY) * scale)}
              width={Math.min(W, viewportW * scale)}
              height={Math.min(H, viewportH * scale)}
              fill="none" stroke="var(--accent)" strokeWidth="1.5"/>
      </svg>
    </div>
  );
}

// ── Search panel (top-right floating) ───────────────────────────────────
function SearchPanel({ query, onChange, results, onJump, onClose }) {
  return (
    <div style={{ position: "absolute", top: 12, right: 12, width: 280,
                  background: "var(--surface)", border: "1px solid var(--line)",
                  borderRadius: 8, padding: 10, zIndex: 5,
                  boxShadow: "0 4px 16px rgba(0,0,0,0.12)" }}>
      <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 6 }}>
        <I.search size={12} style={{ color: "var(--ink-3)" }}/>
        <input className="input" autoFocus placeholder="Search nodes…"
          value={query} onChange={e => onChange(e.target.value)}
          style={{ flex: 1, fontSize: 12, padding: "5px 8px" }}/>
        <button className="topbar-icon-btn" onClick={onClose}><I.x size={12}/></button>
      </div>
      {query && (
        <div style={{ maxHeight: 280, overflowY: "auto", marginTop: 4 }}>
          {results.length === 0
            ? <div style={{ fontSize: 11.5, color: "var(--ink-3)", padding: "8px 4px" }}>No matches.</div>
            : results.map(n => (
              <div key={n.node_key} onClick={() => { onJump(n); onClose(); }}
                style={{ padding: "6px 8px", borderRadius: 5, cursor: "pointer", fontSize: 12,
                         display: "flex", alignItems: "center", gap: 8 }}
                onMouseEnter={(e) => e.currentTarget.style.background = "var(--surface-2)"}
                onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
                <Badge tone="gray">{n.type}</Badge>
                <span style={{ flex: 1 }}>{n.title || n.node_key}</span>
              </div>
            ))}
        </div>
      )}
    </div>
  );
}

// ── Right-click context menu ────────────────────────────────────────────
function NodeContextMenu({ x, y, selectedCount, onClose, onDuplicate, onCopy, onDelete }) {
  React.useEffect(() => {
    const off = () => onClose();
    setTimeout(() => {
      document.addEventListener("click", off);
      document.addEventListener("contextmenu", off);
    }, 0);
    return () => {
      document.removeEventListener("click", off);
      document.removeEventListener("contextmenu", off);
    };
  }, [onClose]);
  const items = [
    { label: selectedCount > 1 ? `Duplicate ${selectedCount} nodes` : "Duplicate", icon: I.copy, action: onDuplicate, shortcut: "Ctrl+D" },
    { label: "Copy", icon: I.copy, action: onCopy, shortcut: "Ctrl+C" },
    { label: "Delete", icon: I.trash, action: onDelete, shortcut: "Del", danger: true },
  ];
  return (
    <div style={{ position: "fixed", top: y, left: x, zIndex: 1000,
                  background: "var(--surface)", border: "1px solid var(--line)",
                  borderRadius: 8, padding: 4, minWidth: 200,
                  boxShadow: "0 6px 20px rgba(0,0,0,0.18)" }}
         onClick={e => e.stopPropagation()}>
      {items.map((it, i) => (
        <div key={i} onClick={it.action}
          style={{ display: "flex", alignItems: "center", gap: 8, padding: "7px 10px",
                   fontSize: 12.5, cursor: "pointer", borderRadius: 5,
                   color: it.danger ? "#f87171" : "var(--ink-1)" }}
          onMouseEnter={(e) => e.currentTarget.style.background = "var(--surface-2)"}
          onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
          <it.icon size={12}/>
          <span style={{ flex: 1 }}>{it.label}</span>
          <span style={{ fontSize: 10.5, color: "var(--ink-3)" }}>{it.shortcut}</span>
        </div>
      ))}
    </div>
  );
}



// ============================================================================
// NodeInspector — schema-driven config form (advanced + simple modes)
// ============================================================================
function NodeInspector({ node, registry, edges, allNodes, advancedMode, onToggleAdvanced,
                          onChange, onChangeData, onDelete, onClose }) {
  const meta = registry?.nodes[node.type];
  if (!meta) return null;

  // Split fields by simple/advanced
  const simpleFields   = (meta.fields || []).filter(f => f.simple !== false);
  const advancedFields = (meta.fields || []).filter(f => f.simple === false);
  const visibleFields  = advancedMode ? [...simpleFields, ...advancedFields] : simpleFields;
  const Icon = I[meta.icon] || I.flow;

  // For variable-style hints: build the list of known variables from previous nodes.
  const knownVars = collectKnownVariables(node, allNodes, edges, registry);

  return (
    <div style={{ width: 320, borderLeft: "1px solid var(--line)", padding: 0, overflowY: "auto",
                  background: "var(--surface)", flexShrink: 0, display: "flex", flexDirection: "column" }}>
      {/* Header */}
      <div style={{ padding: "12px 14px", borderBottom: "1px solid var(--line)" }}>
        <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
          <div style={{ width: 28, height: 28, borderRadius: 7, background: meta.color + "22",
                        display: "grid", placeItems: "center", color: meta.color }}>
            <Icon size={14}/>
          </div>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontSize: 13, fontWeight: 600 }}>{meta.label}</div>
            <div style={{ fontSize: 10.5, color: "var(--ink-3)" }}>{node.node_key} · {node.type}</div>
          </div>
          <button className="topbar-icon-btn" onClick={onClose} title="Close"><I.x size={13}/></button>
        </div>
        <div style={{ fontSize: 11.5, color: "var(--ink-3)", lineHeight: 1.5 }}>
          {meta.description}
        </div>
      </div>

      {/* Mode toggle bar */}
      {advancedFields.length > 0 && (
        <div style={{ padding: "8px 14px", borderBottom: "1px solid var(--line)",
                      display: "flex", alignItems: "center", gap: 8, background: "var(--surface-2)" }}>
          <span style={{ fontSize: 11, color: "var(--ink-3)" }}>Show advanced options</span>
          <div style={{ flex: 1 }}/>
          <Toggle value={advancedMode} onChange={onToggleAdvanced}/>
        </div>
      )}

      {/* Always-visible fields: title + subtitle */}
      <div style={{ padding: "12px 14px", borderBottom: "1px solid var(--line)" }}>
        <Field label="Title" hint="Display name shown on the canvas.">
          <input className="input" value={node.title || ""}
            onChange={e => onChange({ title: e.target.value })}/>
        </Field>
        <div style={{ height: 10 }}/>
        <Field label="Subtitle" hint="One-line description.">
          <input className="input" value={node.subtitle || ""}
            onChange={e => onChange({ subtitle: e.target.value })}/>
        </Field>
      </div>

      {/* Schema-driven fields */}
      <div style={{ padding: "12px 14px", flex: 1 }}>
        {visibleFields.length === 0 ? (
          <div style={{ fontSize: 11.5, color: "var(--ink-3)", padding: 16, textAlign: "center" }}>
            No additional configuration for this node.
          </div>
        ) : visibleFields.map(f => (
          <NodeField key={f.key} field={f} value={node.data?.[f.key]} knownVars={knownVars}
            onChange={(v) => onChangeData({ [f.key]: v })} advancedMode={advancedMode}/>
        ))}

        {/* Variable hints */}
        {advancedMode && knownVars.length > 0 && (
          <div style={{ marginTop: 16, padding: 10, background: "var(--surface-2)", borderRadius: 6, fontSize: 11 }}>
            <div style={{ fontWeight: 600, color: "var(--ink-3)", marginBottom: 6 }}>Available variables</div>
            <div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
              {knownVars.map(v => (
                <code key={v} style={{ background: "var(--surface)", padding: "2px 6px", borderRadius: 4, fontSize: 10.5 }}>
                  {"{{"}{v}{"}}"}
                </code>
              ))}
            </div>
          </div>
        )}
      </div>

      {/* Connected edges */}
      <div style={{ padding: "10px 14px", borderTop: "1px solid var(--line)" }}>
        <div style={{ fontSize: 11, fontWeight: 600, color: "var(--ink-3)", marginBottom: 6, letterSpacing: "0.04em" }}>
          CONNECTIONS
        </div>
        {edges.filter(e => e.from_key === node.node_key || e.to_key === node.node_key).map((e, i) => {
          const other = e.from_key === node.node_key ? e.to_key : e.from_key;
          const dir = e.from_key === node.node_key ? "→" : "←";
          const otherNode = allNodes.find(n => n.node_key === other);
          return (
            <div key={i} style={{ padding: "3px 0", display: "flex", alignItems: "center", gap: 6, fontSize: 11.5, color: "var(--ink-2)" }}>
              <span style={{ color: "var(--ink-3)" }}>{dir}</span>
              <span style={{ flex: 1 }}>{otherNode?.title || other}</span>
              {e.label && <Badge tone="gray">{e.label}</Badge>}
            </div>
          );
        })}
        {edges.filter(e => e.from_key === node.node_key || e.to_key === node.node_key).length === 0 && (
          <div style={{ fontSize: 11, color: "var(--ink-3)" }}>Not connected yet — drag from an output port.</div>
        )}
      </div>

      {/* Delete button */}
      <div style={{ padding: "12px 14px", borderTop: "1px solid var(--line)" }}>
        <Btn kind="ghost" size="sm" style={{ color: "var(--err)", width: "100%", justifyContent: "center" }}
          icon={<I.trash size={12}/>} onClick={onDelete}>Delete node</Btn>
      </div>
    </div>
  );
}

// ── Collect variable names from upstream nodes (used for autocomplete hints) ──
function collectKnownVariables(currentNode, allNodes, edges, registry) {
  // BFS backward from current node, gather any `save_as` declarations.
  const found = new Set(["lead.name", "lead.phone", "lead.email", "lead.company", "call.id", "call.duration"]);
  const adj = {};
  for (const e of edges) (adj[e.to_key] = adj[e.to_key] || []).push(e.from_key);
  const queue = [currentNode.node_key];
  const seen = new Set();
  while (queue.length) {
    const cur = queue.shift();
    if (seen.has(cur)) continue;
    seen.add(cur);
    const node = allNodes.find(n => n.node_key === cur);
    if (node && cur !== currentNode.node_key) {
      const saveAs = node.data?.save_as;
      if (saveAs) found.add(saveAs);
      const saveResp = node.data?.save_response_as;
      if (saveResp) found.add(saveResp);
      // Variable node: pull declared names
      for (const op of (node.data?.ops || [])) {
        if (op?.name) found.add(op.name);
      }
    }
    for (const upstream of (adj[cur] || [])) queue.push(upstream);
  }
  return Array.from(found).sort();
}

// ============================================================================
// NodeField — renders one config field based on its `kind`
// ============================================================================
function NodeField({ field, value, onChange, knownVars, advancedMode }) {
  const hint = field.hint;
  const label = field.label + (field.simple === false ? " ·" : "");
  const labelEl = (
    <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
      <span>{field.label}</span>
      {field.simple === false && <Badge tone="gray">advanced</Badge>}
    </div>
  );

  // Wrapping every field in some consistent spacing.
  const wrap = (children) => (
    <div style={{ marginBottom: 12 }}>
      <div className="field-label" style={{ display: "flex", alignItems: "center", gap: 6 }}>
        <span>{field.label}</span>
        {field.simple === false && <span style={{ fontSize: 9.5, color: "var(--ink-3)", padding: "1px 5px",
          background: "var(--surface-2)", borderRadius: 3 }}>adv</span>}
      </div>
      {children}
      {hint && <div className="field-hint" style={{ fontSize: 10.5 }}>{hint}</div>}
    </div>
  );

  switch (field.kind) {
    case "text":
      return wrap(<input className="input" value={value || ""} placeholder={field.placeholder || ""}
                   onChange={e => onChange(e.target.value)}/>);
    case "textarea":
      return wrap(<textarea className="textarea" rows={field.rows || 3} value={value || ""}
                   placeholder={field.placeholder || ""}
                   onChange={e => onChange(e.target.value)}/>);
    case "number":
      return wrap(<input className="input" type="number" value={value ?? ""}
                   step={field.step || 1} min={field.min} max={field.max}
                   placeholder={field.placeholder || ""}
                   onChange={e => onChange(e.target.value === "" ? null : Number(e.target.value))}/>);
    case "select":
      return wrap(
        <select className="select" value={value ?? (field.options?.[0]?.value || "")}
                onChange={e => onChange(e.target.value)}>
          {(field.options || []).map(o =>
            <option key={o.value} value={o.value}>{o.label}</option>)}
        </select>
      );
    case "toggle":
      return wrap(<Toggle value={!!value} onChange={onChange}/>);
    case "tags":
      return wrap(<TagsInput value={value || []} onChange={onChange} placeholder={field.placeholder}/>);
    case "kvList":
      return wrap(<KvListInput value={value || []} onChange={onChange}
                    keyLabel={field.keyLabel} valueLabel={field.valueLabel}/>);
    case "conditionList":
      return wrap(<ConditionListInput value={value || []} onChange={onChange} knownVars={knownVars}/>);
    case "varOpList":
      return wrap(<VarOpListInput value={value || []} onChange={onChange}/>);
    case "variable":
      return wrap(<input className="input" value={value || ""} placeholder={field.placeholder || "variable_name"}
                   onChange={e => onChange(e.target.value.replace(/[^\w.]/g, ""))}/>);
    case "json":
      return wrap(<textarea className="textarea" rows={field.rows || 6}
                   style={{ fontFamily: "var(--font-mono)", fontSize: 11.5 }}
                   placeholder={field.placeholder || '{}'}
                   value={value || ""}
                   onChange={e => onChange(e.target.value)}/>);
    case "kbCollection":
      return wrap(<KbCollectionSelect value={value} onChange={onChange}/>);
    case "modelSelect":
      return wrap(<ModelSelect value={value} onChange={onChange}/>);
    case "integrationSelect":
      return wrap(<IntegrationSelect value={value} onChange={onChange} category={field.category}/>);
    case "flowSelect":
      return wrap(<FlowSelect value={value} onChange={onChange}/>);
    default:
      return wrap(<input className="input" value={value || ""} onChange={e => onChange(e.target.value)}/>);
  }
}

// ── Field input components ──────────────────────────────────────────────

function TagsInput({ value, onChange, placeholder }) {
  const [draft, setDraft] = React.useState("");
  const tags = Array.isArray(value) ? value : [];
  const add = (s) => {
    const t = s.trim();
    if (!t) return;
    if (tags.includes(t)) return;
    onChange([...tags, t]);
    setDraft("");
  };
  return (
    <div style={{ border: "1px solid var(--line)", borderRadius: 6, padding: "4px 4px 0",
                  background: "var(--surface)", minHeight: 34 }}>
      <div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
        {tags.map((t, i) => (
          <span key={i} style={{ display: "inline-flex", alignItems: "center", gap: 4,
                                  background: "var(--surface-2)", padding: "2px 8px",
                                  borderRadius: 12, fontSize: 11 }}>
            {t}
            <button style={{ all: "unset", cursor: "pointer", color: "var(--ink-3)", padding: 2 }}
              onClick={() => onChange(tags.filter((_, j) => j !== i))}>×</button>
          </span>
        ))}
        <input style={{ border: "none", outline: "none", background: "transparent", flex: 1, minWidth: 80, fontSize: 12, padding: "4px 4px 6px" }}
          value={draft} placeholder={placeholder || "Type and press Enter…"}
          onChange={e => setDraft(e.target.value)}
          onKeyDown={e => {
            if (e.key === "Enter" || e.key === ",") { e.preventDefault(); add(draft); }
            else if (e.key === "Backspace" && !draft && tags.length) onChange(tags.slice(0, -1));
          }}/>
      </div>
    </div>
  );
}

function KvListInput({ value, onChange, keyLabel, valueLabel }) {
  const list = Array.isArray(value) ? value : [];
  const update = (i, field, v) => {
    const next = list.map((row, j) => j === i ? { ...row, [field]: v } : row);
    onChange(next);
  };
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
      {list.map((row, i) => (
        <div key={i} style={{ display: "flex", gap: 4 }}>
          <input className="input" placeholder={keyLabel || "key"} value={row.key || ""}
            onChange={e => update(i, "key", e.target.value)} style={{ flex: 1, fontSize: 11.5, padding: "5px 7px" }}/>
          <input className="input" placeholder={valueLabel || "value"} value={row.value || ""}
            onChange={e => update(i, "value", e.target.value)} style={{ flex: 1.5, fontSize: 11.5, padding: "5px 7px" }}/>
          <button className="topbar-icon-btn" onClick={() => onChange(list.filter((_, j) => j !== i))}>
            <I.x size={11}/>
          </button>
        </div>
      ))}
      <Btn kind="ghost" size="sm" icon={<I.plus size={11}/>}
        onClick={() => onChange([...list, { key: "", value: "" }])}>Add row</Btn>
    </div>
  );
}

function ConditionListInput({ value, onChange, knownVars }) {
  const list = Array.isArray(value) ? value : [];
  const ops = ["==", "!=", ">", ">=", "<", "<=", "contains", "starts_with", "ends_with", "empty", "not_empty"];
  const update = (i, field, v) => onChange(list.map((row, j) => j === i ? { ...row, [field]: v } : row));
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
      {list.map((row, i) => (
        <div key={i} style={{ display: "grid", gridTemplateColumns: "1fr 90px 1fr 80px 28px", gap: 4, alignItems: "center" }}>
          <input className="input" placeholder="{{var}}" value={row.left || ""}
            onChange={e => update(i, "left", e.target.value)} style={{ fontSize: 11, padding: "4px 6px" }}/>
          <select className="select" value={row.op || "=="} onChange={e => update(i, "op", e.target.value)}
            style={{ fontSize: 11, padding: "4px 6px" }}>
            {ops.map(o => <option key={o} value={o}>{o}</option>)}
          </select>
          <input className="input" placeholder="value" value={row.right || ""}
            onChange={e => update(i, "right", e.target.value)}
            style={{ fontSize: 11, padding: "4px 6px" }}
            disabled={row.op === "empty" || row.op === "not_empty"}/>
          <input className="input" placeholder="edge label" value={row.label || ""}
            onChange={e => update(i, "label", e.target.value)} style={{ fontSize: 11, padding: "4px 6px" }}/>
          <button className="topbar-icon-btn" onClick={() => onChange(list.filter((_, j) => j !== i))}>
            <I.x size={11}/>
          </button>
        </div>
      ))}
      <Btn kind="ghost" size="sm" icon={<I.plus size={11}/>}
        onClick={() => onChange([...list, { left: "", op: "==", right: "", label: "" }])}>Add rule</Btn>
    </div>
  );
}

function VarOpListInput({ value, onChange }) {
  const list = Array.isArray(value) ? value : [];
  const actions = [
    { value: "set", label: "Set" },
    { value: "append", label: "Append" },
    { value: "increment", label: "Increment" },
    { value: "clear", label: "Clear" },
  ];
  const update = (i, field, v) => onChange(list.map((row, j) => j === i ? { ...row, [field]: v } : row));
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
      {list.map((row, i) => (
        <div key={i} style={{ display: "grid", gridTemplateColumns: "90px 1fr 1fr 28px", gap: 4 }}>
          <select className="select" value={row.action || "set"} onChange={e => update(i, "action", e.target.value)}
            style={{ fontSize: 11, padding: "4px 6px" }}>
            {actions.map(a => <option key={a.value} value={a.value}>{a.label}</option>)}
          </select>
          <input className="input" placeholder="variable_name" value={row.name || ""}
            onChange={e => update(i, "name", e.target.value.replace(/[^\w.]/g, ""))}
            style={{ fontSize: 11, padding: "4px 6px" }}/>
          <input className="input" placeholder="value or {{var}}" value={row.value || ""}
            onChange={e => update(i, "value", e.target.value)}
            style={{ fontSize: 11, padding: "4px 6px" }}
            disabled={row.action === "clear"}/>
          <button className="topbar-icon-btn" onClick={() => onChange(list.filter((_, j) => j !== i))}>
            <I.x size={11}/>
          </button>
        </div>
      ))}
      <Btn kind="ghost" size="sm" icon={<I.plus size={11}/>}
        onClick={() => onChange([...list, { action: "set", name: "", value: "" }])}>Add operation</Btn>
    </div>
  );
}

// ── Smart selectors (pull live data from the API) ───────────────────────
function KbCollectionSelect({ value, onChange }) {
  const [list, setList] = React.useState(null);
  React.useEffect(() => {
    VoaisAPI.get("/api/kb/collections").then(r => {
      if (r.ok && r.data?.ok) setList(r.data.collections || []);
    }).catch(() => setList([]));
  }, []);
  if (list === null) return <div style={{ fontSize: 11, color: "var(--ink-3)" }}>Loading…</div>;
  if (list.length === 0) return <div style={{ fontSize: 11, color: "var(--ink-3)" }}>No KB collections yet — create one in Knowledge Base.</div>;
  return (
    <select className="select" value={value || ""} onChange={e => onChange(e.target.value ? Number(e.target.value) : null)}>
      <option value="">— pick a collection —</option>
      {list.map(c => <option key={c.id} value={c.id}>{c.name} ({c.total_docs || 0} docs)</option>)}
    </select>
  );
}

function ModelSelect({ value, onChange }) {
  const [providers, setProviders] = React.useState(null);
  React.useEffect(() => {
    VoaisAPI.get("/api/settings/ai-providers").then(r => {
      if (r.ok && r.data?.ok) setProviders(r.data.providers || []);
    }).catch(() => setProviders([]));
  }, []);
  if (providers === null) return <div style={{ fontSize: 11, color: "var(--ink-3)" }}>Loading…</div>;
  const allModels = [];
  providers.forEach(p => (p.models || []).forEach(m => allModels.push({ id: m.id, label: `${p.display_name} · ${m.name}` })));
  return (
    <select className="select" value={value || ""} onChange={e => onChange(e.target.value || null)}>
      <option value="">— use agent default —</option>
      {allModels.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
    </select>
  );
}

function IntegrationSelect({ value, onChange, category }) {
  const [list, setList] = React.useState(null);
  React.useEffect(() => {
    VoaisAPI.get("/api/integrations").then(r => {
      if (r.ok && r.data?.ok) setList(r.data.integrations || []);
    }).catch(() => setList([]));
  }, []);
  if (list === null) return <div style={{ fontSize: 11, color: "var(--ink-3)" }}>Loading…</div>;
  const filtered = category ? list.filter(i => i.category === category) : list;
  if (filtered.length === 0) {
    return <div style={{ fontSize: 11, color: "var(--ink-3)" }}>
      No {category || "integrations"} connected. <a href="#" onClick={(e) => { e.preventDefault();
        window.dispatchEvent(new CustomEvent("voais:navigate", { detail: { route: "integrations" } })); }}>Connect one →</a>
    </div>;
  }
  return (
    <select className="select" value={value || ""} onChange={e => onChange(e.target.value || null)}>
      <option value="">— pick one —</option>
      {filtered.map(i => <option key={i.key} value={i.key}>{i.display_name || i.key}{i.connected ? "" : " (not connected)"}</option>)}
    </select>
  );
}

function FlowSelect({ value, onChange }) {
  const [list, setList] = React.useState(null);
  React.useEffect(() => {
    VoaisAPI.get("/api/flows").then(r => {
      if (r.ok && r.data?.ok) setList(r.data.flows || []);
    }).catch(() => setList([]));
  }, []);
  if (list === null) return <div style={{ fontSize: 11, color: "var(--ink-3)" }}>Loading…</div>;
  const published = list.filter(f => f.status === "published");
  if (published.length === 0) {
    return <div style={{ fontSize: 11, color: "var(--ink-3)" }}>No published flows yet — publish another flow first.</div>;
  }
  return (
    <select className="select" value={value || ""} onChange={e => onChange(e.target.value ? Number(e.target.value) : null)}>
      <option value="">— pick a flow —</option>
      {published.map(f => <option key={f.id} value={f.id}>{f.name}</option>)}
    </select>
  );
}


// ============================================================================
// Flow Test — run the flow in your browser (engine-driven, mic + AI voice)
// ----------------------------------------------------------------------------
// Thin client over /api/flows/test/ws. The SERVER walks the flow node-by-node
// and tells us what to do: say a line (TTS), gather a digit, listen for speech,
// or open a realtime AI session for an `ai_reply` node (mic in / AI voice out).
// ============================================================================

const TEST_SR = 8000; // AI-node audio sample rate (PCM16 8 kHz, both ways)

function wsBaseFromApi() {
  const base = (window.VOAIS_API_BASE || "").replace(/\/$/, "");
  if (base) return base.replace(/^http/, "ws");
  return (location.protocol === "https:" ? "wss:" : "ws:") + "//" + location.host;
}
function downsample(input, inRate, outRate) {
  if (outRate >= inRate) return input;
  const ratio = inRate / outRate, outLen = Math.floor(input.length / ratio), out = new Float32Array(outLen);
  let oi = 0, ii = 0;
  while (oi < outLen) {
    const next = Math.floor((oi + 1) * ratio);
    let sum = 0, c = 0;
    for (; ii < next && ii < input.length; ii++) { sum += input[ii]; c++; }
    out[oi++] = c ? sum / c : 0;
  }
  return out;
}
function floatToPcm16B64(f32) {
  const u8 = new Uint8Array(f32.length * 2), dv = new DataView(u8.buffer);
  for (let i = 0; i < f32.length; i++) { let s = Math.max(-1, Math.min(1, f32[i])); dv.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true); }
  let str = ""; const ch = 0x8000;
  for (let i = 0; i < u8.length; i += ch) str += String.fromCharCode.apply(null, u8.subarray(i, i + ch));
  return btoa(str);
}
function pcm16FromB64(b64) {
  const bin = atob(b64), u8 = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
  return new Int16Array(u8.buffer);
}

function FlowTestPanel({ flow, nodes, edges, registry, onClose, onHighlight }) {
  const [models, setModels] = React.useState([]);
  const [modelId, setModelId] = React.useState("");
  const [phase, setPhase]   = React.useState("idle"); // idle | live | ended | error
  const [statusMsg, setMsg] = React.useState("Press Dial to run the flow.");
  const [transcript, setTr] = React.useState([]);      // [{ kind:"agent|user|sys", text }]
  const [mode, setMode]     = React.useState(null);    // null | "listen" | "dtmf" | "ai"
  const [dtmfKeys, setDtmf] = React.useState(null);    // array of allowed digits or null
  const [reply, setReply]   = React.useState("");

  const wsRef    = React.useRef(null);
  const aliveRef = React.useRef(false);
  const trRef    = React.useRef([]);
  const recogRef = React.useRef(null);
  // mic (AI node)
  const micCtxRef = React.useRef(null);
  const micNodeRef = React.useRef(null);
  const micSrcRef = React.useRef(null);
  const micStreamRef = React.useRef(null);
  // playback (AI node)
  const playCtxRef = React.useRef(null);
  const playQ = React.useRef([]);
  const playing = React.useRef(false);
  const audioElRef = React.useRef(null); // current TTS <audio>

  React.useEffect(() => {
    VoaisAPI.get("/api/flows/test/models").then(r => {
      if (r.ok && r.data && r.data.ok) {
        const ms = r.data.models || [];
        setModels(ms);
        const first = ms.find(m => m.available) || ms[0];
        if (first) setModelId(first.id);
      }
    });
  }, []);

  const addMsg = React.useCallback((text, kind) => {
    const next = trRef.current.concat([{ kind, text }]);
    trRef.current = next; setTr(next);
  }, []);

  // ── playback (AI PCM16 8k) ──────────────────────────────────────────────
  const stopPlayback = React.useCallback(() => { playQ.current = []; playing.current = false; }, []);
  const playNext = React.useCallback(() => {
    if (!playQ.current.length) { playing.current = false; return; }
    playing.current = true;
    const b64 = playQ.current.shift();
    try {
      let ctx = playCtxRef.current;
      if (!ctx) { ctx = new (window.AudioContext || window.webkitAudioContext)(); playCtxRef.current = ctx; }
      const i16 = pcm16FromB64(b64);
      const buf = ctx.createBuffer(1, i16.length, TEST_SR);
      const chan = buf.getChannelData(0);
      for (let i = 0; i < i16.length; i++) chan[i] = i16[i] / 32768;
      const src = ctx.createBufferSource();
      src.buffer = buf; src.connect(ctx.destination); src.onended = playNext; src.start();
    } catch (_) { playNext(); }
  }, []);
  const enqueueAudio = React.useCallback((b64) => { playQ.current.push(b64); if (!playing.current) playNext(); }, [playNext]);

  // ── mic capture for AI nodes (PCM16 8k upstream) ────────────────────────
  const startMic = React.useCallback(async () => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
      micStreamRef.current = stream;
      const ctx = new (window.AudioContext || window.webkitAudioContext)();
      micCtxRef.current = ctx;
      const srcNode = ctx.createMediaStreamSource(stream); micSrcRef.current = srcNode;
      const proc = ctx.createScriptProcessor(2048, 1, 1); micNodeRef.current = proc;
      srcNode.connect(proc); proc.connect(ctx.destination);
      proc.onaudioprocess = (e) => {
        if (!aliveRef.current || !wsRef.current || wsRef.current.readyState !== 1) return;
        const ds = downsample(e.inputBuffer.getChannelData(0), ctx.sampleRate, TEST_SR);
        wsRef.current.send(JSON.stringify({ t: "audio", b64: floatToPcm16B64(ds) }));
      };
    } catch (e) { addMsg("mic error: " + e.message, "sys"); }
  }, [addMsg]);
  const stopMic = React.useCallback(() => {
    try { if (micNodeRef.current) { micNodeRef.current.onaudioprocess = null; micNodeRef.current.disconnect(); } } catch (_) {}
    try { if (micSrcRef.current) micSrcRef.current.disconnect(); } catch (_) {}
    try { if (micStreamRef.current) micStreamRef.current.getTracks().forEach(t => t.stop()); } catch (_) {}
    try { if (micCtxRef.current) micCtxRef.current.close(); } catch (_) {}
    micNodeRef.current = micSrcRef.current = micStreamRef.current = micCtxRef.current = null;
  }, []);

  const browserSpeak = React.useCallback((text, after) => {
    if (!("speechSynthesis" in window)) { setTimeout(after, Math.min(4000, 400 + text.length * 35)); return; }
    try { const u = new SpeechSynthesisUtterance(text); u.rate = 1.02; u.onend = after; u.onerror = after; window.speechSynthesis.cancel(); window.speechSynthesis.speak(u); }
    catch (_) { after(); }
  }, []);

  // ── TTS for `say` (server MP3, browser-voice fallback) ──────────────────
  const speak = React.useCallback((text, then) => {
    if (text) addMsg(text, "agent");
    const after = () => { if (then === "advance" && wsRef.current && wsRef.current.readyState === 1) wsRef.current.send(JSON.stringify({ t: "continue" })); };
    if (!text) { after(); return; }
    let settled = false;
    const done = (ok) => { if (settled) return; settled = true; if (ok) after(); else browserSpeak(text, after); };
    try {
      const a = new Audio("/api/tts?text=" + encodeURIComponent(text) + "&lang=en");
      audioElRef.current = a;
      a.onended = () => done(true);
      a.onerror = () => done(false);
      a.play().catch(() => done(false));
      setTimeout(() => done(true), Math.min(15000, 1500 + text.length * 90));
    } catch (_) { done(false); }
  }, [addMsg, browserSpeak]);

  const finalize = React.useCallback(() => {
    aliveRef.current = false;
    stopMic(); stopPlayback();
    try { if (audioElRef.current) audioElRef.current.pause(); } catch (_) {}
    try { if ("speechSynthesis" in window) window.speechSynthesis.cancel(); } catch (_) {}
    try { if (recogRef.current) recogRef.current.stop(); } catch (_) {}
    try { if (wsRef.current) wsRef.current.close(); } catch (_) {}
    wsRef.current = null;
    setMode(null); setDtmf(null);
    onHighlight && onHighlight(null);
  }, [stopMic, stopPlayback, onHighlight]);

  React.useEffect(() => () => finalize(), [finalize]); // cleanup on unmount

  const sendSpeech = React.useCallback((text) => {
    const t = String(text || "").trim();
    if (!t || !wsRef.current || wsRef.current.readyState !== 1) return;
    addMsg(t, "user");
    wsRef.current.send(JSON.stringify({ t: "speech", text: t }));
    setMode(null); setReply("");
    try { if (recogRef.current) recogRef.current.stop(); } catch (_) {}
  }, [addMsg]);

  const startRecog = React.useCallback(() => {
    const R = window.SpeechRecognition || window.webkitSpeechRecognition;
    if (!R) return; // no speech API — user types instead
    try {
      const r = new R(); recogRef.current = r;
      r.lang = "en-IN"; r.interimResults = false; r.maxAlternatives = 1;
      r.onresult = (e) => sendSpeech(e.results[0][0].transcript);
      r.onerror = () => {}; r.start();
    } catch (_) {}
  }, [sendSpeech]);

  const onServer = React.useCallback((m) => {
    switch (m.t) {
      case "node": onHighlight && onHighlight(m.nodeId); break;
      case "say": speak(m.text, m.then); break;
      case "listen":
        if (m.input === "dtmf") { setMode("dtmf"); setDtmf((m.digits && m.digits.length) ? m.digits : ["1","2","3","4","5","6","7","8","9","*","0","#"]); setMsg("Tap a keypad digit"); }
        else { setMode("listen"); setDtmf(null); setMsg("Listening — type your reply (or speak)"); startRecog(); }
        break;
      case "ai_ready": setMode("ai"); setMsg("AI live (" + (m.provider || "echo") + ") — talk into your mic"); startMic(); break;
      case "ai_audio": enqueueAudio(m.b64); break;
      case "ai_text": if (m.text) { addMsg(m.text, "agent"); browserSpeak(m.text, () => {}); } break;
      case "ai_interrupted": stopPlayback(); break;
      case "transcript": if (m.text) addMsg(m.text, m.role === "user" ? "user" : "agent"); break;
      case "log": addMsg(m.msg, "sys"); break;
      case "hangup": setMsg("Call ended (" + (m.reason || "") + ")"); break;
      case "ended": setPhase("ended"); finalize(); break;
    }
  }, [speak, startRecog, startMic, enqueueAudio, stopPlayback, addMsg, browserSpeak, finalize, onHighlight]);

  const sendDigit = React.useCallback((k) => {
    if (!wsRef.current || wsRef.current.readyState !== 1) return;
    addMsg("⌨ " + k, "user");
    wsRef.current.send(JSON.stringify({ t: "dtmf", digit: k }));
    setMode(null); setDtmf(null);
  }, [addMsg]);

  const aiDone = React.useCallback(() => {
    stopMic();
    if (wsRef.current && wsRef.current.readyState === 1) wsRef.current.send(JSON.stringify({ t: "ai_done" }));
    setMode(null);
  }, [stopMic]);

  const dial = React.useCallback(() => {
    setTr([]); trRef.current = []; setPhase("live"); setMsg("Connecting…");
    try { if ("speechSynthesis" in window) { window.speechSynthesis.resume(); const w = new SpeechSynthesisUtterance(" "); w.volume = 0; window.speechSynthesis.speak(w); } } catch (_) {}
    const token = (VoaisAPI.getSession && VoaisAPI.getSession() && VoaisAPI.getSession().token) || "";
    const ws = new WebSocket(wsBaseFromApi() + "/api/flows/test/ws?token=" + encodeURIComponent(token));
    wsRef.current = ws; aliveRef.current = true;
    ws.onopen = () => {
      setMsg("Call in progress…");
      ws.send(JSON.stringify({ t: "start", model: modelId, flow: { nodes, edges }, vars: { name: "Test User" } }));
    };
    ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch (_) { return; } onServer(m); };
    ws.onerror = () => { setPhase("error"); setMsg("Connection error."); };
    ws.onclose = () => { setPhase(p => (p === "error" ? p : "ended")); aliveRef.current = false; };
  }, [modelId, nodes, edges, onServer]);

  const hangup = React.useCallback(() => {
    if (wsRef.current && aliveRef.current) { try { wsRef.current.send(JSON.stringify({ t: "hangup" })); } catch (_) {} }
    finalize(); setPhase("ended"); setMsg("Stopped.");
  }, [finalize]);

  const live = phase === "live";
  return (
    <Modal title={"Test flow — " + (flow?.name || "")} onClose={() => { finalize(); onClose(); }} width={660}>
      <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
        <div style={{ fontSize: 12.5, color: "var(--ink-3)", lineHeight: 1.5 }}>
          Runs <b>{flow?.name || "this flow"}</b> step by step in your browser — scripted lines are spoken, choices are gathered, and <b>AI</b> nodes connect to the selected model with your mic. Nothing is dialed. The active node is highlighted on the canvas.
        </div>

        <div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
          <select className="select" style={{ maxWidth: 280 }} value={modelId} disabled={live} onChange={e => setModelId(e.target.value)}>
            {models.length === 0 && <option value="">Loading models…</option>}
            {models.map(m => <option key={m.id} value={m.id}>{m.label}{m.available ? "" : " — no key"}</option>)}
          </select>
          {!live
            ? <Btn kind="primary" icon={<I.phone size={14}/>} disabled={!modelId} onClick={dial}>Dial</Btn>
            : <Btn kind="danger" icon={<I.x size={14}/>} onClick={hangup}>Hang up</Btn>}
          <Badge tone={phase === "live" ? "green" : phase === "error" ? "red" : "gray"} dot>{phase}</Badge>
          <span style={{ fontSize: 12, color: "var(--ink-3)" }}>{statusMsg}</span>
        </div>

        <div style={{ border: "1px solid var(--line)", borderRadius: 8, height: 230, overflowY: "auto", padding: 12, display: "flex", flexDirection: "column", gap: 7 }}>
          {transcript.length === 0 && <div style={{ color: "var(--ink-3)", fontSize: 12.5, margin: "auto" }}>Transcript appears here.</div>}
          {transcript.map((t, i) => t.kind === "sys"
            ? <div key={i} style={{ fontSize: 11, color: "var(--ink-3)", fontStyle: "italic", textAlign: "center" }}>{t.text}</div>
            : <div key={i} style={{ display: "flex", justifyContent: t.kind === "user" ? "flex-end" : "flex-start" }}>
                <div style={{ maxWidth: "78%", padding: "7px 11px", borderRadius: 10, fontSize: 13, lineHeight: 1.45,
                  background: t.kind === "user" ? "var(--accent-soft)" : "var(--bg-2, rgba(127,127,127,0.12))", color: "var(--ink-1)" }}>
                  <div style={{ fontSize: 10.5, color: "var(--ink-3)", marginBottom: 2 }}>{t.kind === "user" ? "You" : "AI"}</div>{t.text}
                </div>
              </div>)}
        </div>

        {mode === "dtmf" && dtmfKeys && (
          <div style={{ display: "grid", gridTemplateColumns: "repeat(6, 1fr)", gap: 6 }}>
            {dtmfKeys.map(k => <Btn key={k} kind="secondary" size="sm" onClick={() => sendDigit(k)}>{k}</Btn>)}
          </div>
        )}
        {mode === "listen" && (
          <div style={{ display: "flex", gap: 8 }}>
            <input className="input" style={{ flex: 1 }} autoFocus placeholder="Type the caller's reply…" value={reply}
              onChange={e => setReply(e.target.value)} onKeyDown={e => { if (e.key === "Enter") sendSpeech(reply); }}/>
            <Btn kind="primary" icon={<I.send size={13}/>} onClick={() => sendSpeech(reply)}>Send</Btn>
          </div>
        )}
        {mode === "ai" && (
          <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
            <Badge tone="green" dot>mic live</Badge>
            <span style={{ fontSize: 12, color: "var(--ink-3)", flex: 1 }}>Talk to the AI, then continue the flow.</span>
            <Btn kind="primary" size="sm" icon={<I.play size={13}/>} onClick={aiDone}>Continue flow</Btn>
          </div>
        )}

        <div style={{ fontSize: 11, color: "var(--ink-3)" }}>
          Needs mic permission (HTTPS or localhost). AI nodes use the selected model — set its key in the server env (e.g. <code>GEMINI_API_KEY</code>); with no key the AI node runs in echo mode. Scripted lines use server TTS.
        </div>
      </div>
    </Modal>
  );
}


// Expose
window.FlowBuilderScreen = FlowBuilderScreen;
