import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SignalPicker, { SignalOption } from './SignalPicker';
import LuaEditor from './LuaEditor';
import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types';
import { inferNodeTypes, portAccept, typesCompatible, SynthType } from './lib/synthTypes';
import { VersionTree, DiffViewer } from './VersionHistory';
import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup, type Rect } from './lib/nodeGroups';
import { useFlowZoom, type Box } from './lib/flowZoom';
import { useFlowDebug, formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug';
import { useAuth } from './lib/auth';

interface DataSource { name: string; }
interface SignalInfo { name: string; type?: string; }

interface Props {
  // Existing signal name (edit mode). Omitted/empty in create mode.
  name?: string;
  // When true the editor starts blank and POSTs a new signal on save.
  create?: boolean;
  // Interface id used to bind panel-scoped signals when creating.
  panelId?: string;
  onClose: () => void;
  onSaved: () => void;
}

// ── Node-type catalogue ──────────────────────────────────────────────────────
// Mirrors the backend dsp node set (internal/dsp/nodes.go). `arity` describes how
// many input ports an op exposes:
//   fixed n — exactly n inputs (gain=1, subtract/divide=2, …)
//   min n   — at least n inputs; one spare port appears past the wired count so the
//             user can keep adding (add/multiply)
//   named   — the user names each input (expr/lua); ports follow params.vars
type InArity = { kind: 'fixed'; n: number } | { kind: 'min'; n: number } | { kind: 'named' };
interface NodeParam { label: string; key: string; type: 'number' | 'text' | 'lua'; default: string; }
interface OpDef { type: string; label: string; arity: InArity; params: NodeParam[]; }

const OPS: OpDef[] = [
  { type: 'gain',           label: 'Gain',           arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
  { type: 'offset',         label: 'Offset',         arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
  { type: 'add',            label: 'Add (Σ inputs)', arity: { kind: 'min', n: 2 },   params: [] },
  { type: 'subtract',       label: 'Subtract (a−b)', arity: { kind: 'fixed', n: 2 }, params: [] },
  { type: 'multiply',       label: 'Multiply (Π)',   arity: { kind: 'min', n: 2 },   params: [] },
  { type: 'divide',         label: 'Divide (a÷b)',   arity: { kind: 'fixed', n: 2 }, params: [] },
  { type: 'moving_average', label: 'Moving Average', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
  { type: 'rms',            label: 'RMS',            arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
  { type: 'lowpass',        label: 'Low-pass',       arity: { kind: 'fixed', n: 1 }, params: [
    { label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
    { label: 'Order (1–8)', key: 'order', type: 'number', default: '1' },
  ]},
  { type: 'derivative',     label: 'Derivative',     arity: { kind: 'fixed', n: 1 }, params: [] },
  { type: 'integrate',      label: 'Integrate',      arity: { kind: 'fixed', n: 1 }, params: [] },
  { type: 'clamp',          label: 'Clamp',          arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
  { type: 'threshold',      label: 'Threshold',      arity: { kind: 'fixed', n: 1 }, params: [
    { label: 'Threshold', key: 'threshold', type: 'number', default: '0' },
    { label: 'High output', key: 'high', type: 'number', default: '1' },
    { label: 'Low output', key: 'low', type: 'number', default: '0' },
  ]},
  { type: 'expr',           label: 'Formula',        arity: { kind: 'named' }, params: [{ label: 'Expression', key: 'expr', type: 'text', default: 'a + b' }] },
  { type: 'lua',            label: 'Lua Script',     arity: { kind: 'named' }, params: [{ label: 'Script', key: 'script', type: 'lua', default: 'return a + b' }] },
  // ── Array (waveform) ops ──
  { type: 'index',          label: 'Index (a[i])',   arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Index (i)', key: 'i', type: 'number', default: '0' }] },
  { type: 'slice',          label: 'Slice',          arity: { kind: 'fixed', n: 1 }, params: [
    { label: 'Start', key: 'start', type: 'number', default: '0' },
    { label: 'End (0 = to end)', key: 'end', type: 'number', default: '0' },
  ]},
  { type: 'sum',            label: 'Sum (Σ array)',  arity: { kind: 'fixed', n: 1 }, params: [] },
  { type: 'mean',           label: 'Mean (array)',   arity: { kind: 'fixed', n: 1 }, params: [] },
  { type: 'min',            label: 'Min (array)',    arity: { kind: 'fixed', n: 1 }, params: [] },
  { type: 'max',            label: 'Max (array)',    arity: { kind: 'fixed', n: 1 }, params: [] },
  { type: 'length',         label: 'Length',         arity: { kind: 'fixed', n: 1 }, params: [] },
  { type: 'fft',            label: 'FFT (magnitude)', arity: { kind: 'fixed', n: 1 }, params: [] },
];
const OP_BY_TYPE = new Map(OPS.map(o => [o.type, o]));
function opLabel(type: string): string { return OP_BY_TYPE.get(type)?.label ?? type; }
function opParamDefs(type: string): NodeParam[] { return OP_BY_TYPE.get(type)?.params ?? []; }
function opArity(type?: string): InArity { return OP_BY_TYPE.get(type ?? '')?.arity ?? { kind: 'fixed', n: 1 }; }

// Default input names a,b,c,… for named (expr/lua) nodes.
function letters(n: number): string[] {
  return Array.from({ length: Math.max(1, n) }, (_, i) => String.fromCharCode(97 + i));
}

// ── Graph model (UI only — compiled to SynthGraph on save) ──────────────────
type NodeKind = 'source' | 'op' | 'output';
interface GNode {
  id: string;
  kind: NodeKind;
  x: number;
  y: number;
  ds?: string;      // source
  signal?: string;  // source
  op?: string;      // op type
  params?: Record<string, any>; // op (named ops carry params.vars: string[])
}
// A wire terminates on a specific input port (index) of the target node.
interface GWire { from: string; to: string; toPort: number; }
interface Graph { nodes: GNode[]; wires: GWire[]; groups?: NodeGroup[]; }

// ── Geometry (rem-based, like the logic editor) ─────────────────────────────
const REM = (() => {
  if (typeof document === 'undefined') return 16;
  return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
})();
const NODE_W = 10 * REM;
// Logical canvas size (matches .flow-canvas-inner in styles.css); zoom scales it.
const CANVAS_W = 125 * REM;
const CANVAS_H = 87.5 * REM;
const PORT_TOP = 1.7 * REM;   // y of the first input/output port row
const PORT_GAP = 1.25 * REM;  // vertical spacing between stacked input ports
const PORT_R = 0.375 * REM;
// Ports are absolutely positioned inside the node's padding box, which is offset
// from the node's border-box origin (node.x/node.y) by the node borders: a 3px
// colored accent on top and 1px on the sides. Wire anchors must add the same
// offsets or they land at the top edge of the port circle instead of its center.
const BORDER = 1;
const BORDER_TOP = 3;
// ── Group geometry (node grouping / collapsing) ─────────────────────────────
const GROUP_PAD = 0.65 * REM;     // padding around member nodes
const GROUP_HEADER = 1.65 * REM;  // height of the group title bar
const GROUP_BOX_W = NODE_W;       // collapsed-box width
const GROUP_BOX_H = GROUP_HEADER + 1.1 * REM; // collapsed-box height

function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); }
function hasOutput(k: NodeKind): boolean { return k !== 'output'; }

// Number of input ports a node currently shows.
function inPortCount(n: GNode, wires: GWire[]): number {
  if (n.kind === 'source') return 0;
  if (n.kind === 'output') return 1;
  const ar = opArity(n.op);
  if (ar.kind === 'fixed') return ar.n;
  if (ar.kind === 'named') return Math.max(1, (n.params?.vars as string[] | undefined)?.length ?? 0);
  // min: expose one spare port beyond the highest wired index so the user can add more.
  const wired = wires.filter(w => w.to === n.id).length;
  return Math.max(ar.n, wired + 1);
}
function inAnchor(n: GNode, port: number) {
  return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP + port * PORT_GAP };
}
function outAnchor(n: GNode) { return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
function nodeMinHeight(n: GNode, wires: GWire[]): number {
  const nIn = Math.max(1, inPortCount(n, wires));
  return BORDER_TOP + PORT_TOP + nIn * PORT_GAP;
}
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
  const dx = Math.max(40, Math.abs(x2 - x1) / 2);
  return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}

// Label shown beside an input port (named ops show their var name).
function portLabel(n: GNode, port: number): string {
  if (opArity(n.op).kind === 'named') {
    const vars = (n.params?.vars as string[] | undefined) ?? [];
    return vars[port] ?? '';
  }
  return '';
}

// Build an initial graph from an existing SignalDef. Prefer the stored DAG; fall
// back to laying out the legacy linear Inputs+Pipeline form.
function buildInitial(def: SignalDef): Graph {
  if (def.graph && def.graph.nodes.length > 0) {
    const nodes: GNode[] = def.graph.nodes.map((n, i) => ({
      id: n.id,
      kind: n.kind,
      x: n.x ?? (2 + (i % 3) * 12) * REM,
      y: n.y ?? (2 + Math.floor(i / 3) * 5) * REM,
      ds: n.ds,
      signal: n.signal,
      op: n.op,
      params: n.params ? { ...n.params } : undefined,
    }));
    const wires: GWire[] = [];
    for (const n of def.graph.nodes) {
      (n.inputs ?? []).forEach((from, port) => wires.push({ from, to: n.id, toPort: port }));
    }
    const groups = def.graph.groups;
    return { nodes, wires, ...(groups && groups.length ? { groups: groups.map(g => ({ ...g, members: [...g.members] })) } : {}) };
  }

  // Legacy linear layout: sources stacked left, pipeline a row of op nodes, output right.
  const inputs = def.inputs && def.inputs.length > 0
    ? def.inputs
    : (def.ds && def.signal ? [{ ds: def.ds, signal: def.signal }] : []);
  const pipeline = def.pipeline ?? [];
  const nodes: GNode[] = [];
  const wires: GWire[] = [];

  const srcIds = inputs.map((inp, i) => {
    const id = genId();
    nodes.push({ id, kind: 'source', x: 2 * REM, y: (2 + i * 5) * REM, ds: inp.ds, signal: inp.signal });
    return id;
  });
  const opIds = pipeline.map((nd, i) => {
    const id = genId();
    const params: Record<string, any> = { ...nd.params };
    // The head op received every source as a,b,c,…; later ops a single input.
    if (opArity(nd.type).kind === 'named') params.vars = letters(i === 0 ? srcIds.length : 1);
    nodes.push({ id, kind: 'op', x: (15 + i * 12) * REM, y: 3 * REM, op: nd.type, params });
    return id;
  });
  const outId = genId();
  nodes.push({ id: outId, kind: 'output', x: (15 + pipeline.length * 12) * REM, y: 3 * REM });

  const headId = opIds[0] ?? outId;
  srcIds.forEach((s, i) => wires.push({ from: s, to: headId, toPort: i }));
  for (let i = 0; i < opIds.length - 1; i++) wires.push({ from: opIds[i], to: opIds[i + 1], toPort: 0 });
  if (opIds.length > 0) wires.push({ from: opIds[opIds.length - 1], to: outId, toPort: 0 });

  return { nodes, wires };
}

// Compile the visual graph into the backend DAG form. Each op/output node's
// inputs are its wires ordered by target port index.
function compile(g: Graph): SynthGraph {
  const output = g.nodes.find(n => n.kind === 'output');
  const nodes: SynthGraphNode[] = g.nodes.map(n => {
    const inputs = g.wires
      .filter(w => w.to === n.id)
      .slice()
      .sort((a, b) => a.toPort - b.toPort)
      .map(w => w.from);
    if (n.kind === 'source') return { id: n.id, kind: 'source', ds: n.ds, signal: n.signal, x: n.x, y: n.y };
    if (n.kind === 'output') return { id: n.id, kind: 'output', inputs, x: n.x, y: n.y };
    return { id: n.id, kind: 'op', op: n.op, params: n.params, inputs, x: n.x, y: n.y };
  });
  const groups = pruneGroups(g.groups, new Set(g.nodes.map(n => n.id)));
  return { nodes, output: output?.id ?? '', ...(groups.length ? { groups } : {}) };
}

// Detect a cycle in the graph (Kahn count over input edges).
function hasCycle(g: Graph): boolean {
  const indeg = new Map<string, number>();
  const succ = new Map<string, string[]>();
  for (const n of g.nodes) indeg.set(n.id, 0);
  for (const w of g.wires) {
    if (!indeg.has(w.to) || !indeg.has(w.from)) continue;
    indeg.set(w.to, (indeg.get(w.to) ?? 0) + 1);
    succ.set(w.from, [...(succ.get(w.from) ?? []), w.to]);
  }
  const queue = g.nodes.filter(n => (indeg.get(n.id) ?? 0) === 0).map(n => n.id);
  let visited = 0;
  while (queue.length) {
    const id = queue.shift()!;
    visited++;
    for (const s of succ.get(id) ?? []) {
      indeg.set(s, (indeg.get(s) ?? 0) - 1);
      if ((indeg.get(s) ?? 0) === 0) queue.push(s);
    }
  }
  return visited !== g.nodes.length;
}

// Validate the graph; return a per-node error map plus the first message found.
function validate(g: Graph): { errors: Map<string, string>; first?: string } {
  const errors = new Map<string, string>();
  const set = (id: string, msg: string) => { if (!errors.has(id)) errors.set(id, msg); };

  if (!g.nodes.some(n => n.kind === 'output')) {
    return { errors, first: 'missing output node' };
  }

  for (const n of g.nodes) {
    const ports = new Set(g.wires.filter(w => w.to === n.id).map(w => w.toPort));
    if (n.kind === 'source') {
      if (!n.ds || !n.signal) set(n.id, 'pick a data source and signal');
      continue;
    }
    if (n.kind === 'output') {
      if (!ports.has(0)) set(n.id, 'connect a value to the output');
      continue;
    }
    const ar = opArity(n.op);
    if (ar.kind === 'fixed' || ar.kind === 'named') {
      const cnt = ar.kind === 'fixed' ? ar.n : ((n.params?.vars as string[] | undefined)?.length ?? 0);
      if (cnt === 0) { set(n.id, 'add at least one named input'); continue; }
      for (let i = 0; i < cnt; i++) {
        if (!ports.has(i)) { set(n.id, 'all inputs must be connected'); break; }
      }
    } else {
      // min: needs ≥ ar.n contiguous inputs starting at port 0.
      const cnt = ports.size;
      if (cnt < ar.n) { set(n.id, `needs at least ${ar.n} inputs`); continue; }
      for (let i = 0; i < cnt; i++) {
        if (!ports.has(i)) { set(n.id, 'inputs have a gap — reconnect them'); break; }
      }
    }
  }

  let first: string | undefined;
  if (hasCycle(g)) first = 'the graph contains a cycle';
  if (!first) {
    for (const n of g.nodes) {
      const m = errors.get(n.id);
      if (m) { first = `${n.kind === 'op' ? opLabel(n.op ?? '') : n.kind}: ${m}`; break; }
    }
  }
  return { errors, first };
}

function nodeSummary(n: GNode): string {
  if (n.kind === 'source') return n.ds && n.signal ? `${n.ds}:${n.signal}` : '(pick a signal)';
  if (n.kind === 'output') return 'synthetic result';
  const p = n.params ?? {};
  switch (n.op) {
    case 'gain':           return `× ${p.gain ?? 1}`;
    case 'offset':         return `+ ${p.offset ?? 0}`;
    case 'moving_average': return `avg ${p.window ?? 10}`;
    case 'rms':            return `rms ${p.window ?? 10}`;
    case 'lowpass':        return `≤ ${p.freq ?? 1} Hz`;
    case 'clamp':          return `[${p.min ?? 0}, ${p.max ?? 100}]`;
    case 'threshold':      return `> ${p.threshold ?? 0}`;
    case 'expr':           return String(p.expr ?? '');
    case 'lua':            return 'lua';
    default:               return opLabel(n.op ?? '');
  }
}

export default function SyntheticGraphEditor({ name, create, panelId, onClose, onSaved }: Props) {
  const [def, setDef] = useState<SignalDef | null>(null);
  const [graph, setGraph] = useState<Graph>({ nodes: [], wires: [] });
  const [selected, setSelected] = useState<string | null>(null);
  const [selectedWire, setSelectedWire] = useState<number | null>(null);
  // Multi-selection (Shift+click) and the selected group, for node grouping.
  const [selSet, setSelSet] = useState<Set<string>>(new Set());
  const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState('');
  // Quick-add HUD (S = signal, N = node); null when closed.
  const [hud, setHud] = useState<null | 'signal' | 'node'>(null);
  const [hudFilter, setHudFilter] = useState('');
  // Version history pane (existing signals only).
  const [showVersions, setShowVersions] = useState(false);
  const [viewingVersion, setViewingVersion] = useState<number | null>(null);
  const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null);
  const [verReload, setVerReload] = useState(0);

  // Identity fields — editable only when creating a new signal.
  const me = useAuth();
  const [newName, setNewName] = useState('');
  const [visibility, setVisibility] = useState<'panel' | 'user' | 'group' | 'global'>(panelId ? 'panel' : 'user');
  const [scopeGroups, setScopeGroups] = useState<string[]>([]);
  const [unit, setUnit] = useState('');
  const [desc, setDesc] = useState('');
  const [dispLow, setDispLow] = useState('0');
  const [dispHigh, setDispHigh] = useState('100');
  const sigName = create ? newName.trim() : (name ?? '');

  const [dataSources, setDataSources] = useState<string[]>([]);
  const [dsSignals, setDsSignals] = useState<Record<string, SignalInfo[]>>({});

  const canvasRef = useRef<HTMLDivElement>(null);
  const zoomRef = useRef(1);
  const dragNode = useRef<{ ids: string[]; ox: number; oy: number; starts: Map<string, { x: number; y: number }>; pushed: boolean } | null>(null);
  const [pendingWire, setPendingWire] = useState<{ from: string; x: number; y: number } | null>(null);
  const pendingRef = useRef(pendingWire);
  pendingRef.current = pendingWire;

  const graphRef = useRef(graph);
  graphRef.current = graph;
  const undoStack = useRef<Graph[]>([]);
  const redoStack = useRef<Graph[]>([]);
  const clipboard = useRef<{ nodes: GNode[]; wires: GWire[] } | null>(null);
  const [debug, setDebug] = useState(false);
  const [, setTick] = useState(0);
  const bump = () => setTick(t => t + 1);
  const canUndo = undoStack.current.length > 0;
  const canRedo = redoStack.current.length > 0;

  useEffect(() => {
    fetch('/api/v1/datasources')
      .then(r => r.ok ? r.json() : [])
      .then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
      .catch(() => {});
  }, []);

  async function loadSignals(ds: string) {
    if (!ds || dsSignals[ds]) return;
    try {
      const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
      if (!res.ok) return;
      const sigs: SignalInfo[] = await res.json();
      setDsSignals(prev => ({ ...prev, [ds]: sigs }));
    } catch {}
  }

  // Flatten every known ds:name into a single list for the fuzzy SignalPicker,
  // and a hook to lazily load all data sources' signals when a picker opens.
  function allSignalOptions(): SignalOption[] {
    const out: SignalOption[] = [];
    for (const ds of dataSources) for (const s of (dsSignals[ds] ?? [])) out.push({ ds, name: s.name });
    return out;
  }
  function openAllSignals() { dataSources.forEach(ds => loadSignals(ds)); }

  // Resolve a source node's data type from the upstream signal's metadata.
  // 'float64[]' is the only array type today; anything else is scalar, and an
  // unresolved/unloaded source is 'unknown' (no error, runtime typing wins).
  function sourceSynthType(n: SynthGraphNode): SynthType {
    if (!n.ds || !n.signal) return 'unknown';
    const sigs = dsSignals[n.ds];
    if (!sigs) return 'unknown';
    const info = sigs.find(s => s.name === n.signal);
    if (!info || !info.type) return 'unknown';
    return info.type === 'float64[]' ? 'array' : 'scalar';
  }

  // HUD filtering: case-insensitive substring over signal name/ds and op label/type.
  function filteredSignalOptions(): SignalOption[] {
    const q = hudFilter.trim().toLowerCase();
    const all = allSignalOptions();
    if (!q) return all;
    return all.filter(o => o.name.toLowerCase().includes(q) || o.ds.toLowerCase().includes(q));
  }
  function filteredOps(): OpDef[] {
    const q = hudFilter.trim().toLowerCase();
    if (!q) return OPS;
    return OPS.filter(o => o.label.toLowerCase().includes(q) || o.type.toLowerCase().includes(q));
  }

  useEffect(() => {
    if (create) {
      const blank: SignalDef = { name: '', inputs: [], pipeline: [], meta: {} };
      setDef(blank);
      setGraph(buildInitial(blank));
      setLoading(false);
      return;
    }
    fetch(`/api/v1/synthetic/${encodeURIComponent(name ?? '')}`)
      .then(r => r.ok ? r.json() : Promise.reject(r.statusText))
      .then((d: SignalDef) => {
        setDef(d);
        setUnit(d.meta?.unit ?? '');
        setDesc(d.meta?.description ?? '');
        setDispLow(String(d.meta?.displayLow ?? '0'));
        setDispHigh(String(d.meta?.displayHigh ?? '100'));
        const g = buildInitial(d);
        setGraph(g);
        g.nodes.forEach(n => { if (n.kind === 'source' && n.ds) loadSignals(n.ds); });
      })
      .catch(e => setError(String(e)))
      .finally(() => setLoading(false));
  }, [name, create]);

  // ── History ────────────────────────────────────────────────────────────────
  function pushUndo() {
    undoStack.current = [...undoStack.current.slice(-49), graphRef.current];
    redoStack.current = [];
  }
  // Commit a new graph. When `next.groups` is left undefined the current groups
  // are carried over, so ordinary node/wire edits never lose grouping; group
  // mutators pass an explicit (possibly empty) list to change it.
  function commit(next: Graph, record = true) {
    if (record) pushUndo();
    const groups = next.groups !== undefined ? next.groups : graphRef.current.groups;
    setGraph({ nodes: next.nodes, wires: next.wires, ...(groups && groups.length ? { groups } : {}) });
  }
  function undo() {
    if (undoStack.current.length === 0) return;
    const prev = undoStack.current[undoStack.current.length - 1];
    redoStack.current = [graphRef.current, ...redoStack.current];
    undoStack.current = undoStack.current.slice(0, -1);
    setSelected(null); setSelectedWire(null); setSelSet(new Set()); setSelectedGroup(null);
    setGraph(prev); bump();
  }
  function redo() {
    if (redoStack.current.length === 0) return;
    const next = redoStack.current[0];
    undoStack.current = [...undoStack.current, graphRef.current];
    redoStack.current = redoStack.current.slice(1);
    setSelected(null); setSelectedWire(null); setSelSet(new Set()); setSelectedGroup(null);
    setGraph(next); bump();
  }

  // ── Graph mutators ───────────────────────────────────────────────────────
  function addSource() {
    const node: GNode = { id: genId(), kind: 'source', x: 2 * REM, y: (2 + graph.nodes.filter(n => n.kind === 'source').length * 5) * REM, ds: dataSources[0] || '', signal: '' };
    if (node.ds) loadSignals(node.ds);
    commit({ nodes: [...graph.nodes, node], wires: graph.wires });
    setSelected(node.id); setSelectedWire(null);
  }
  // Add a source node pre-filled with a chosen ds:signal (used by the quick-add HUD).
  function addSourceWith(ds: string, signal: string) {
    const node: GNode = { id: genId(), kind: 'source', x: 2 * REM, y: (2 + graph.nodes.filter(n => n.kind === 'source').length * 5) * REM, ds, signal };
    if (ds) loadSignals(ds);
    commit({ nodes: [...graph.nodes, node], wires: graph.wires });
    setSelected(node.id); setSelectedWire(null);
  }
  // Open the quick-add HUD; for signals, lazily load every source's signal list.
  function openHud(kind: 'signal' | 'node') {
    if (kind === 'signal') openAllSignals();
    setHudFilter('');
    setHud(kind);
  }
  function closeHud() { setHud(null); setHudFilter(''); }
  function addOp(op: OpDef, x?: number, y?: number) {
    const params: Record<string, any> = {};
    for (const p of op.params) params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default;
    if (op.arity.kind === 'named') params.vars = ['a', 'b'];
    const node: GNode = { id: genId(), kind: 'op', op: op.type, params, x: x ?? (14 + (graph.nodes.length % 4) * 3) * REM, y: y ?? (3 + (graph.nodes.length % 4) * 2) * REM };
    commit({ nodes: [...graph.nodes, node], wires: graph.wires });
    setSelected(node.id); setSelectedWire(null);
  }
  function patchNode(id: string, patch: Partial<GNode>) {
    commit({ nodes: graph.nodes.map(n => (n.id === id ? { ...n, ...patch } : n)), wires: graph.wires });
  }
  function patchParam(id: string, key: string, val: string) {
    commit({
      nodes: graph.nodes.map(n => {
        if (n.id !== id) return n;
        const pd = opParamDefs(n.op ?? '').find(p => p.key === key);
        const typed: any = pd?.type === 'number' ? parseFloat(val) : val;
        return { ...n, params: { ...n.params, [key]: typed } };
      }),
      wires: graph.wires,
    });
  }
  // Named-input (expr/lua) variable list editing.
  function addNamedVar(id: string) {
    commit({
      nodes: graph.nodes.map(n => {
        if (n.id !== id) return n;
        const vars = [...((n.params?.vars as string[] | undefined) ?? [])];
        vars.push(letters(vars.length + 1)[vars.length]);
        return { ...n, params: { ...n.params, vars } };
      }),
      wires: graph.wires,
    });
  }
  function renameNamedVar(id: string, idx: number, value: string) {
    commit({
      nodes: graph.nodes.map(n => {
        if (n.id !== id) return n;
        const vars = [...((n.params?.vars as string[] | undefined) ?? [])];
        vars[idx] = value;
        return { ...n, params: { ...n.params, vars } };
      }),
      wires: graph.wires,
    });
  }
  function removeNamedVar(id: string, idx: number) {
    const nodes = graph.nodes.map(n => {
      if (n.id !== id) return n;
      const vars = [...((n.params?.vars as string[] | undefined) ?? [])];
      vars.splice(idx, 1);
      return { ...n, params: { ...n.params, vars } };
    });
    // Drop the wire at the removed port and shift higher ports down by one.
    const wires = graph.wires
      .filter(w => !(w.to === id && w.toPort === idx))
      .map(w => (w.to === id && w.toPort > idx ? { ...w, toPort: w.toPort - 1 } : w));
    commit({ nodes, wires });
  }
  // Move several nodes at once (group / multi-select drag). `pos` maps id→{x,y}.
  function moveNodes(pos: Map<string, { x: number; y: number }>, record: boolean) {
    const g = graphRef.current;
    commit({ nodes: g.nodes.map(n => (pos.has(n.id) ? { ...n, ...pos.get(n.id)! } : n)), wires: g.wires }, record);
  }
  function deleteNode(id: string) {
    const n = graph.nodes.find(x => x.id === id);
    if (!n || n.kind === 'output') return; // the output node is permanent
    const nextNodes = graph.nodes.filter(x => x.id !== id);
    const nextGroups = pruneGroups(graph.groups, new Set(nextNodes.map(nn => nn.id)));
    commit({ nodes: nextNodes, wires: graph.wires.filter(w => w.from !== id && w.to !== id), groups: nextGroups });
    if (selected === id) setSelected(null);
    if (selSet.has(id)) { const s = new Set(selSet); s.delete(id); setSelSet(s); }
  }

  // ── Clipboard ────────────────────────────────────────────────────────────
  // Copy the current selection (multi-select aware) plus any wires internal to
  // it, with params deep-cloned. The permanent output node is never copied.
  function copySelection() {
    const cur = graphRef.current;
    const ids = selSet.size > 0 ? selSet : (selected ? new Set([selected]) : new Set<string>());
    const picked = cur.nodes.filter(n => ids.has(n.id) && n.kind !== 'output');
    if (picked.length === 0) return;
    const pickedIds = new Set(picked.map(n => n.id));
    const innerWires = cur.wires.filter(w => pickedIds.has(w.from) && pickedIds.has(w.to));
    clipboard.current = {
      nodes: picked.map(n => ({ ...n, params: n.params ? JSON.parse(JSON.stringify(n.params)) : undefined })),
      wires: innerWires.map(w => ({ ...w })),
    };
  }
  function pasteClipboard() {
    const clip = clipboard.current;
    if (!clip || clip.nodes.length === 0) return;
    const idMap = new Map<string, string>();
    const cur = graphRef.current;
    const newNodes = clip.nodes.map(n => {
      const id = genId();
      idMap.set(n.id, id);
      return { ...n, id, x: n.x + 1.5 * REM, y: n.y + 1.5 * REM, params: n.params ? JSON.parse(JSON.stringify(n.params)) : undefined };
    });
    const newWires = clip.wires
      .filter(w => idMap.has(w.from) && idMap.has(w.to))
      .map(w => ({ ...w, from: idMap.get(w.from)!, to: idMap.get(w.to)! }));
    for (const n of newNodes) if (n.kind === 'source' && n.ds) loadSignals(n.ds);
    commit({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires] });
    setSelSet(new Set(newNodes.map(n => n.id)));
    setSelected(newNodes.length === 1 ? newNodes[0].id : null);
    setSelectedWire(null);
    setSelectedGroup(null);
  }

  // ── Groups ──────────────────────────────────────────────────────────────────
  function setGroups(next: NodeGroup[], record = true) {
    const g = graphRef.current;
    commit({ nodes: g.nodes, wires: g.wires, groups: next }, record);
  }
  function groupSelection() {
    if (selSet.size < 1) return;
    const members = graph.nodes.filter(n => selSet.has(n.id)).map(n => n.id);
    if (members.length < 1) return;
    const grp: NodeGroup = { id: genGroupId(), label: 'Group', members, collapsed: false };
    setGroups([...(graph.groups ?? []), grp]);
    setSelectedGroup(grp.id); setSelected(null); setSelectedWire(null);
  }
  function ungroup(gid: string) {
    setGroups((graph.groups ?? []).filter(g => g.id !== gid));
    if (selectedGroup === gid) setSelectedGroup(null);
  }
  function toggleCollapse(gid: string) {
    setGroups((graph.groups ?? []).map(g => (g.id === gid ? { ...g, collapsed: !g.collapsed } : g)));
  }
  function renameGroup(gid: string, label: string) {
    setGroups((graph.groups ?? []).map(g => (g.id === gid ? { ...g, label } : g)), false);
  }
  function addWire(from: string, to: string, toPort: number) {
    if (from === to) return;
    const fn = graph.nodes.find(n => n.id === from);
    const tn = graph.nodes.find(n => n.id === to);
    if (!fn || !tn || !hasOutput(fn.kind) || tn.kind === 'source') return;
    // Reject a definitely type-incompatible link (e.g. a scalar into an array
    // reduction). 'unknown'/'any' ports never block — runtime typing is final.
    const { types } = inferNodeTypes(compile(graph), sourceSynthType);
    const accept = tn.kind === 'op' ? portAccept(tn.op ?? '') : 'any';
    if (!typesCompatible(types.get(from) ?? 'unknown', accept)) {
      setError(`Incompatible connection: ${accept} port can't take a ${types.get(from)} value`);
      return;
    }
    // One wire per input port: replace any existing wire on that port.
    const wires = graph.wires.filter(w => !(w.to === to && w.toPort === toPort));
    if (wires.some(w => w.from === from && w.to === to && w.toPort === toPort)) return;
    commit({ nodes: graph.nodes, wires: [...wires, { from, to, toPort }] });
  }
  function deleteWire(idx: number) {
    commit({ nodes: graph.nodes, wires: graph.wires.filter((_, i) => i !== idx) });
    if (selectedWire === idx) setSelectedWire(null);
  }

  // The lowest input port of `target` not yet wired (for node-body drops).
  function firstFreePort(target: GNode): number {
    const used = new Set(graphRef.current.wires.filter(w => w.to === target.id).map(w => w.toPort));
    const cnt = inPortCount(target, graphRef.current.wires);
    for (let i = 0; i < cnt; i++) if (!used.has(i)) return i;
    return cnt; // all full — append (min-arity nodes grow)
  }

  // ── Pointer / drag / wire ──────────────────────────────────────────────────
  function toCanvas(e: MouseEvent) {
    const el = canvasRef.current!;
    const rect = el.getBoundingClientRect();
    const z = zoomRef.current;
    return { x: (e.clientX - rect.left + el.scrollLeft) / z, y: (e.clientY - rect.top + el.scrollTop) / z };
  }
  // Begin dragging `ids` (one node, a multi-selection, or a whole group). All
  // members move together, keeping their relative offsets.
  function beginDrag(e: MouseEvent, ids: string[]) {
    const p = toCanvas(e);
    const starts = new Map<string, { x: number; y: number }>();
    for (const id of ids) {
      const n = graphRef.current.nodes.find(x => x.id === id);
      if (n) starts.set(id, { x: n.x, y: n.y });
    }
    dragNode.current = { ids: [...starts.keys()], ox: p.x, oy: p.y, starts, pushed: false };
  }
  function startNodeDrag(e: MouseEvent, node: GNode) {
    e.stopPropagation();
    // Shift+click toggles the node in the multi-selection without dragging.
    if (e.shiftKey) {
      const s = new Set(selSet);
      if (s.has(node.id)) s.delete(node.id); else s.add(node.id);
      setSelSet(s); setSelected(node.id); setSelectedWire(null); setSelectedGroup(null);
      return;
    }
    setSelectedWire(null); setSelectedGroup(null);
    // Drag the whole multi-selection if this node is part of it, else just it.
    const ids = selSet.has(node.id) && selSet.size > 1 ? [...selSet] : [node.id];
    if (!(selSet.has(node.id) && selSet.size > 1)) setSelSet(new Set([node.id]));
    setSelected(node.id);
    beginDrag(e, ids);
  }
  function startWire(e: MouseEvent, node: GNode) {
    e.stopPropagation();
    const p = toCanvas(e);
    setPendingWire({ from: node.id, x: p.x, y: p.y });
  }
  function endWire() {
    pendingRef.current = null;
    setPendingWire(null);
  }
  function finishWire(target: GNode, port: number) {
    const cur = pendingRef.current;
    if (cur && target.kind !== 'source') addWire(cur.from, target.id, port);
    endWire();
  }

  // Single mount-time pointer handler. Reads live drag/wire state from refs so it
  // never goes stale across re-renders — fixes the "can't drop a dragged node" bug.
  useEffect(() => {
    function onMove(e: MouseEvent) {
      const d = dragNode.current;
      if (d) {
        const p = toCanvas(e);
        const record = !d.pushed;
        d.pushed = true;
        const dx = p.x - d.ox, dy = p.y - d.oy;
        const pos = new Map<string, { x: number; y: number }>();
        for (const [id, s] of d.starts) pos.set(id, { x: Math.max(0, s.x + dx), y: Math.max(0, s.y + dy) });
        moveNodes(pos, record);
        return;
      }
      const cur = pendingRef.current;
      if (cur) {
        const p = toCanvas(e);
        setPendingWire({ ...cur, x: p.x, y: p.y });
      }
    }
    function onUp() {
      dragNode.current = null;
      if (pendingRef.current) endWire();
    }
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
    return () => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
    };
  }, []);

  // ── Palette drag-and-drop ──────────────────────────────────────────────────
  const DRAG_MIME = 'application/x-uopi-synth-op';
  function onCanvasDragOver(e: DragEvent) {
    if (Array.from(e.dataTransfer?.types ?? []).includes(DRAG_MIME)) {
      e.preventDefault();
      if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
    }
  }
  function onCanvasDrop(e: DragEvent) {
    const type = e.dataTransfer?.getData(DRAG_MIME);
    if (!type) return;
    e.preventDefault();
    const op = OP_BY_TYPE.get(type);
    if (!op) return;
    const p = toCanvas(e);
    addOp(op, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - PORT_TOP));
  }

  // ── Keyboard ────────────────────────────────────────────────────────────────
  useEffect(() => {
    function onKey(e: KeyboardEvent) {
      const t = e.target as Element;
      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
      const mod = e.ctrlKey || e.metaKey;
      if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
      if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
      if (mod && e.key.toLowerCase() === 'c') { e.preventDefault(); copySelection(); return; }
      if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); pasteClipboard(); return; }
      if (mod) return;
      if (e.key === 'Escape') { if (hud) closeHud(); return; }
      if (e.key.toLowerCase() === 's') { e.preventDefault(); openHud('signal'); return; }
      if (e.key.toLowerCase() === 'n') { e.preventDefault(); openHud('node'); return; }
      // 'g' groups the current multi-selection.
      if (e.key.toLowerCase() === 'g' && selSet.size >= 1) { e.preventDefault(); groupSelection(); return; }
      if (e.key !== 'Delete' && e.key !== 'Backspace') return;
      if (selectedGroup !== null) ungroup(selectedGroup);
      else if (selectedWire !== null) deleteWire(selectedWire);
      else if (selected) deleteNode(selected);
    }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [selected, selectedWire, selectedGroup, selSet, graph, hud]);

  const byId = new Map(graph.nodes.map(n => [n.id, n]));
  const groups = graph.groups ?? [];
  const sel = graph.nodes.find(n => n.id === selected) ?? null;
  const { errors: nodeErrors, first: validationError } = validate(graph);

  // ── Group geometry (for the current render) ─────────────────────────────────
  const rectOf = (id: string): Rect | null => {
    const n = byId.get(id);
    return n ? { x: n.x, y: n.y, w: NODE_W, h: nodeMinHeight(n, graph.wires) } : null;
  };
  const groupGeom = groups.map(g => {
    const bounds = groupBounds(g.members, rectOf, GROUP_PAD, GROUP_HEADER);
    const box: Rect | null = bounds ? { x: bounds.x, y: bounds.y, w: GROUP_BOX_W, h: GROUP_BOX_H } : null;
    return { g, bounds, box };
  });
  const hiddenNodes = new Set<string>();
  const boxByGroup = new Map<string, Rect>();
  for (const gg of groupGeom) if (gg.g.collapsed && gg.box) {
    boxByGroup.set(gg.g.id, gg.box);
    for (const m of gg.g.members) hiddenNodes.add(m);
  }
  const collapsedBoxOf = (id: string): Rect | null => {
    const g = groupContaining(groups, id);
    return g && g.collapsed ? (boxByGroup.get(g.id) ?? null) : null;
  };
  const resolveOut = (n: GNode) => {
    const box = collapsedBoxOf(n.id);
    return box ? { x: box.x + box.w, y: box.y + box.h / 2 } : outAnchor(n);
  };
  const resolveIn = (n: GNode, port: number) => {
    const box = collapsedBoxOf(n.id);
    return box ? { x: box.x, y: box.y + box.h / 2 } : inAnchor(n, port);
  };
  // A wire is hidden when both ends collapse into the same group.
  const wireHidden = (from: string, to: string) => {
    const gf = groupContaining(groups, from), gt = groupContaining(groups, to);
    return !!gf && gf === gt && gf.collapsed;
  };

  // ── Pan / zoom ───────────────────────────────────────────────────────────────
  const getRects = (): Box[] => graph.nodes.map(n => ({ x: n.x, y: n.y, w: NODE_W, h: nodeMinHeight(n, graph.wires) }));
  const { zoom, zoomIn, zoomOut, zoomHome, zoomFit, innerStyle, zoomStyle } =
    useFlowZoom(canvasRef, zoomRef, CANVAS_W, CANVAS_H, getRects);

  // ── Live / debug mode ────────────────────────────────────────────────────────
  // Polls the server with the current (unsaved) graph; each node lights up with
  // its computed value. Stateful DSP nodes are flagged "approx" (single-shot eval).
  const debugSnap: DebugSnapshot = useFlowDebug(debug, async () => {
    const res = await fetch('/api/v1/synthetic/trace', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ graph: compile(graphRef.current) }),
    });
    if (!res.ok) return null;
    const j = await res.json();
    const snap: DebugSnapshot = new Map();
    for (const [id, n] of Object.entries(j.nodes ?? {})) {
      const node = n as { value: any; type?: 'scalar' | 'array'; approx?: boolean };
      snap.set(id, { value: node.value, type: node.type, approx: node.approx, active: node.value != null });
    }
    return snap;
  });

  // Static type propagation: infer each node's output type (scalar/array) to
  // colour wires and surface type-incompatible wirings as node errors. Mirrors
  // the backend dsp.OpOutputType rules (see lib/synthTypes.ts).
  const { types: nodeTypes, errors: typeErrors } = inferNodeTypes(compile(graph), sourceSynthType);
  for (const [id, msg] of typeErrors) if (!nodeErrors.has(id)) nodeErrors.set(id, msg);
  const typeError = !validationError ? [...typeErrors.values()][0] : undefined;
  const firstError = validationError ?? typeError;

  async function handleSave() {
    if (!def) return;
    if (create && !sigName) { setError('Enter a name for the new signal.'); return; }
    if (firstError) { setError(firstError); return; }
    setSaving(true);
    setError('');
    try {
      const meta = {
        ...def.meta,
        unit: unit || undefined,
        description: desc || undefined,
        displayLow: parseFloat(dispLow),
        displayHigh: parseFloat(dispHigh),
      };
      const body: any = {
        ...def,
        name: sigName,
        graph: compile(graph),
        meta,
        inputs: undefined,
        pipeline: undefined,
        ds: undefined,
        signal: undefined,
      };
      if (create) {
        body.visibility = visibility;
        if (visibility === 'panel' && panelId) body.panel = panelId;
        if (visibility === 'group') body.groups = scopeGroups;
      }
      const res = await fetch(
        create ? '/api/v1/synthetic' : `/api/v1/synthetic/${encodeURIComponent(sigName)}`,
        {
          method: create ? 'POST' : 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body),
        },
      );
      if (!res.ok) {
        const j = await res.json().catch(() => ({ error: res.statusText }));
        throw new Error(j.error ?? res.statusText);
      }
      onSaved();
      onClose();
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    } finally {
      setSaving(false);
    }
  }

  // Apply a fetched SignalDef to the editor (meta fields + rebuilt node graph).
  function applyDef(d: SignalDef, asEdit: boolean) {
    setDef(d);
    setUnit(d.meta?.unit ?? '');
    setDesc(d.meta?.description ?? '');
    setDispLow(String(d.meta?.displayLow ?? '0'));
    setDispHigh(String(d.meta?.displayHigh ?? '100'));
    const g = buildInitial(d);
    if (asEdit) commit(g); else { undoStack.current = []; redoStack.current = []; setGraph(g); bump(); }
    g.nodes.forEach(n => { if (n.kind === 'source' && n.ds) loadSignals(n.ds); });
  }

  // Load a past revision into the editor as an undoable edit; saving promotes it
  // to a new current version (non-destructive).
  async function viewVersion(version: number) {
    if (canUndo && !confirm('Discard unsaved changes to load this version?')) return;
    try {
      const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(sigName)}/versions/${version}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const d: SignalDef = await res.json();
      applyDef(d, false);
      setViewingVersion(version);
    } catch (err) {
      setError(`Load version failed: ${err instanceof Error ? err.message : err}`);
    }
  }

  async function reloadCurrent() {
    try {
      const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(sigName)}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      applyDef(await res.json(), false);
      setViewingVersion(null);
    } catch (err) {
      setError(`Reload failed: ${err instanceof Error ? err.message : err}`);
    }
  }

  // Port colour class. Output ports take the node's own inferred output type;
  // input ports take what the op accepts (gray when it accepts either type).
  function portTypeClass(t: SynthType | 'any'): string {
    if (t === 'array') return ' flow-port-type-array';
    if (t === 'scalar') return ' flow-port-type-scalar';
    return ' flow-port-type-any';
  }
  function inPortTypeClass(n: GNode): string {
    return portTypeClass(n.kind === 'op' ? portAccept(n.op ?? '') : 'any');
  }
  function outPortTypeClass(n: GNode): string {
    return portTypeClass(nodeTypes.get(n.id) ?? 'unknown');
  }

  function nodeClass(n: GNode): string {
    const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
    const err = nodeErrors.has(n.id) ? ' flow-node-error' : '';
    return `flow-node flow-node-${cat}${selected === n.id ? ' flow-node-selected' : ''}${err}`;
  }

  return (
    <div class="wizard-backdrop" onClick={onClose}>
      <div class="synth-graph" onClick={(e) => e.stopPropagation()}>
        <div class="wizard-header">
          <span>Synthetic Signal{create ? ' — new' : ` — ${name}`}</span>
          {!create && (
            <button class={`toolbar-btn${showVersions ? ' toolbar-btn-primary' : ''}`}
              style="margin-left:auto;" onClick={() => setShowVersions(v => !v)}>History</button>
          )}
          <button class="icon-btn" onClick={onClose}>✕</button>
        </div>

        {loading ? (
          <div class="wizard-body"><p class="hint">Loading…</p></div>
        ) : (
          <div class="flow-editor">
            <div class="flow-palette">
              <div class="flow-palette-toolbar">
                <button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)">↩</button>
                <button class="toolbar-btn" disabled={!canRedo} onClick={redo} title="Redo (Ctrl+Shift+Z)">↪</button>
                <button class="toolbar-btn" disabled={selSet.size < 1} onClick={groupSelection}
                  title="Group selected nodes (G) — Shift+click nodes to multi-select">⊞</button>
                <button class={`toolbar-btn${debug ? ' flow-debug-on' : ''}`} onClick={() => setDebug(d => !d)}
                  title="Live debug — evaluate the graph and show each node's value">◉</button>
              </div>
              <div class="flow-palette-toolbar">
                <button class="toolbar-btn" onClick={zoomOut} title="Zoom out">−</button>
                <button class="toolbar-btn flow-zoom-pct" onClick={zoomHome} title="Reset zoom (100%)">{Math.round(zoom * 100)}%</button>
                <button class="toolbar-btn" onClick={zoomIn} title="Zoom in">＋</button>
                <button class="toolbar-btn" onClick={zoomFit} title="Fit graph to view">⤢</button>
              </div>
              <div class="flow-palette-title">Inputs</div>
              <button class="flow-palette-btn flow-palette-trigger" onClick={addSource}>+ Signal</button>
              <div class="flow-palette-title">Operations</div>
              {OPS.map(op => (
                <button key={op.type} class="flow-palette-btn flow-palette-flow"
                  title={`${op.type} — drag onto the canvas or click to add`}
                  draggable
                  onDragStart={(e) => { e.dataTransfer?.setData(DRAG_MIME, op.type); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'; }}
                  onClick={() => addOp(op)}>{op.label}</button>
              ))}
              <div class="flow-palette-hint hint">
                Wire signals into each operation's input ports, then connect the result
                to <b>Output</b>. Formula / Lua nodes name their own inputs.
              </div>
            </div>

            <div class="flow-canvas" ref={canvasRef}
              onMouseDown={() => { setSelected(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }}
              onDragOver={onCanvasDragOver}
              onDrop={onCanvasDrop}>
              <div class="flow-canvas-inner" style={innerStyle}>
                <div class="flow-canvas-zoom" style={zoomStyle}>
                {/* Group frames (behind nodes). Collapsed groups render a compact box. */}
                {groupGeom.map(({ g, bounds, box }) => {
                  if (!bounds) return null;
                  const gsel = selectedGroup === g.id;
                  if (g.collapsed && box) {
                    return (
                      <div key={g.id}
                        class={`flow-group-box${gsel ? ' flow-group-selected' : ''}`}
                        style={`left:${box.x}px; top:${box.y}px; width:${box.w}px; height:${box.h}px;`}
                        onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelected(null); setSelectedWire(null); beginDrag(e, g.members); }}>
                        <div class="flow-group-header">
                          <button class="flow-group-caret" title="Expand"
                            onMouseDown={(e) => e.stopPropagation()}
                            onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}>▸</button>
                          <input class="flow-group-label" value={g.label}
                            onMouseDown={(e) => e.stopPropagation()}
                            onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
                        </div>
                        <div class="flow-group-count hint">{g.members.length} node{g.members.length === 1 ? '' : 's'}</div>
                      </div>
                    );
                  }
                  return (
                    <div key={g.id}
                      class={`flow-group-frame${gsel ? ' flow-group-selected' : ''}`}
                      style={`left:${bounds.x}px; top:${bounds.y}px; width:${bounds.w}px; height:${bounds.h}px;`}
                      onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelected(null); setSelectedWire(null); beginDrag(e, g.members); }}>
                      <div class="flow-group-header">
                        <button class="flow-group-caret" title="Collapse"
                          onMouseDown={(e) => e.stopPropagation()}
                          onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}>▾</button>
                        <input class="flow-group-label" value={g.label}
                          onMouseDown={(e) => e.stopPropagation()}
                          onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
                        <button class="flow-group-del" title="Ungroup"
                          onMouseDown={(e) => e.stopPropagation()}
                          onClick={(e) => { e.stopPropagation(); ungroup(g.id); }}>✕</button>
                      </div>
                    </div>
                  );
                })}

                <svg class="flow-wires">
                  {graph.wires.map((w, idx) => {
                    const a = byId.get(w.from); const b = byId.get(w.to);
                    if (!a || !b) return null;
                    if (wireHidden(w.from, w.to)) return null;
                    const p1 = resolveOut(a); const p2 = resolveIn(b, w.toPort);
                    const tClass = nodeTypes.get(w.from) === 'array' ? ' synth-wire-array' : ' synth-wire-scalar';
                    return (
                      <path key={idx}
                        class={`flow-wire${tClass}${selectedWire === idx ? ' flow-wire-selected' : ''}`}
                        d={wirePathStr(p1.x, p1.y, p2.x, p2.y)}
                        onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelected(null); }} />
                    );
                  })}
                  {pendingWire && (() => {
                    const a = byId.get(pendingWire.from);
                    if (!a) return null;
                    const p1 = outAnchor(a);
                    return <path class="flow-wire flow-wire-pending" d={wirePathStr(p1.x, p1.y, pendingWire.x, pendingWire.y)} />;
                  })()}
                </svg>

                {graph.nodes.filter(n => !hiddenNodes.has(n.id)).map(node => {
                  const nIn = inPortCount(node, graph.wires);
                  const dbg = debug ? debugSnap.get(node.id) : undefined;
                  const grouped = groupContaining(groups, node.id) ? ' flow-node-grouped' : '';
                  return (
                    <div key={node.id}
                      class={`${nodeClass(node)}${selSet.has(node.id) && selSet.size > 1 ? ' flow-node-multi' : ''}${dbg ? ' ' + nodeDebugClass(dbg) : ''}${grouped}`}
                      title={nodeErrors.get(node.id) || undefined}
                      style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeMinHeight(node, graph.wires)}px;`}
                      onMouseDown={(e) => startNodeDrag(e, node)}
                      onMouseUp={() => { if (pendingRef.current) finishWire(node, firstFreePort(node)); }}>
                      {dbg && (
                        <span class={`flow-node-badge${dbg.approx ? ' approx' : ''}`} title={badgeTitle(dbg)}>
                          {(dbg.approx ? '~' : '') + formatBadge(dbg)}
                        </span>
                      )}
                      <div class="flow-node-header">
                        <span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
                        {node.kind !== 'output' && (
                          <button class="flow-node-del" title="Delete node"
                            onMouseDown={(e) => e.stopPropagation()}
                            onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}>✕</button>
                        )}
                      </div>
                      <div class="flow-node-body hint">{nodeSummary(node)}</div>

                      {Array.from({ length: nIn }, (_, port) => {
                        const label = portLabel(node, port);
                        return (
                          <Fragment key={port}>
                            <div class={`flow-port flow-port-in${inPortTypeClass(node)}`} title={label || `Input ${port + 1}`}
                              style={`top:${PORT_TOP + port * PORT_GAP - PORT_R}px; left:${-PORT_R}px;`}
                              onMouseDown={(e) => e.stopPropagation()}
                              onMouseUp={(e) => { e.stopPropagation(); finishWire(node, port); }} />
                            {label && (
                              <span class="flow-port-label-in"
                                style={`top:${PORT_TOP + port * PORT_GAP - 0.6 * REM}px;`}>{label}</span>
                            )}
                          </Fragment>
                        );
                      })}
                      {hasOutput(node.kind) && (
                        <div class={`flow-port flow-port-out${outPortTypeClass(node)}`} title="Output"
                          style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
                          onMouseDown={(e) => startWire(e, node)} />
                      )}
                    </div>
                  );
                })}
                </div>
              </div>
            </div>

            <div class="flow-inspector">
              {!sel && (
                <Fragment>
                  <div class="wizard-section-title">Signal</div>
                  {create ? (
                    <Fragment>
                      <div class="wizard-field">
                        <label>Name</label>
                        <input class="prop-input" type="text" value={newName}
                          placeholder="my_signal"
                          onInput={(e) => setNewName((e.target as HTMLInputElement).value)} />
                      </div>
                      <div class="wizard-field">
                        <label>Visibility</label>
                        <select class="prop-input" value={visibility}
                          onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
                          {panelId && <option value="panel">This panel only</option>}
                          <option value="user">My signals</option>
                          <option value="group" disabled={me.groups.length === 0}>A group</option>
                          <option value="global">Global (all users)</option>
                        </select>
                      </div>
                      {visibility === 'group' && (
                        <div class="wizard-field">
                          <label>Groups</label>
                          <div class="scope-picker-groups">
                            {me.groups.length === 0 && <span class="hint">You are not in any group.</span>}
                            {me.groups.map(g => (
                              <label key={g} class="scope-picker-chk">
                                <input type="checkbox" checked={scopeGroups.includes(g)}
                                  onChange={() => setScopeGroups(prev => prev.includes(g) ? prev.filter(x => x !== g) : [...prev, g])} />{g}
                              </label>
                            ))}
                          </div>
                        </div>
                      )}
                    </Fragment>
                  ) : (
                    <p class="hint">Editing <b>{name}</b>.</p>
                  )}
                  <div class="wizard-field">
                    <label>Unit</label>
                    <input class="prop-input" type="text" value={unit}
                      onInput={(e) => setUnit((e.target as HTMLInputElement).value)} />
                  </div>
                  <div class="wizard-field">
                    <label>Description</label>
                    <input class="prop-input" type="text" value={desc}
                      onInput={(e) => setDesc((e.target as HTMLInputElement).value)} />
                  </div>
                  <div class="wizard-field">
                    <label>Display low</label>
                    <input class="prop-input" type="number" value={dispLow}
                      onInput={(e) => setDispLow((e.target as HTMLInputElement).value)} />
                  </div>
                  <div class="wizard-field">
                    <label>Display high</label>
                    <input class="prop-input" type="number" value={dispHigh}
                      onInput={(e) => setDispHigh((e.target as HTMLInputElement).value)} />
                  </div>
                  <div class="flow-palette-hint hint">Select a node to edit it, or add one from the palette.</div>
                </Fragment>
              )}

              {sel?.kind === 'source' && (
                <Fragment>
                  <div class="wizard-section-title">Input signal</div>
                  <div class="wizard-field">
                    <label>Signal</label>
                    <SignalPicker
                      value={sel.signal ? `${sel.ds ?? ''}:${sel.signal}` : ''}
                      options={allSignalOptions()}
                      onOpen={openAllSignals}
                      onChange={(ref) => {
                        const i = ref.indexOf(':');
                        if (i < 0) patchNode(sel.id, { ds: ref, signal: '' });
                        else patchNode(sel.id, { ds: ref.slice(0, i), signal: ref.slice(i + 1) });
                      }} />
                  </div>
                  <button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
                </Fragment>
              )}

              {sel?.kind === 'op' && (
                <Fragment>
                  <div class="wizard-section-title">{opLabel(sel.op ?? '')}</div>
                  {opArity(sel.op).kind === 'named' && (
                    <div class="wizard-field">
                      <label>Inputs</label>
                      {((sel.params?.vars as string[] | undefined) ?? []).map((v, i) => (
                        <div key={i} class="synth-var-row">
                          <input class="prop-input" type="text" value={v}
                            onInput={(e) => renameNamedVar(sel.id, i, (e.target as HTMLInputElement).value)} />
                          <button class="icon-btn" title="Remove input"
                            onClick={() => removeNamedVar(sel.id, i)}>✕</button>
                        </div>
                      ))}
                      <button class="panel-btn" style="margin-top:0.4rem;" onClick={() => addNamedVar(sel.id)}>+ Input</button>
                      <p class="hint" style="margin-top:0.4rem;">Use these names in the {sel.op === 'lua' ? 'script' : 'formula'} below.</p>
                    </div>
                  )}
                  {opParamDefs(sel.op ?? '').length === 0 && opArity(sel.op).kind !== 'named' && (
                    <p class="hint">No parameters — this operation transforms its inputs directly.</p>
                  )}
                  {opParamDefs(sel.op ?? '').map(pd => (
                    <div key={pd.key} class="wizard-field">
                      <label>{pd.label}</label>
                      {pd.type === 'lua' ? (
                        <LuaEditor value={String(sel.params?.[pd.key] ?? pd.default)}
                          onChange={(v) => patchParam(sel.id, pd.key, v)} />
                      ) : (
                        <input class="prop-input" type={pd.type === 'number' ? 'number' : 'text'}
                          value={sel.params?.[pd.key] ?? pd.default}
                          onInput={(e) => patchParam(sel.id, pd.key, (e.target as HTMLInputElement).value)} />
                      )}
                    </div>
                  ))}
                  <button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
                </Fragment>
              )}

              {sel?.kind === 'output' && (
                <Fragment>
                  <div class="wizard-section-title">Output</div>
                  <p class="hint">The value wired into this node becomes the signal <b>{name}</b>.</p>
                </Fragment>
              )}
            </div>
          </div>
        )}

        {hud && (
          <div class="synth-hud-backdrop" onMouseDown={closeHud}>
            <div class="synth-hud" onMouseDown={(e) => e.stopPropagation()}>
              <input
                class="synth-hud-input"
                autoFocus
                placeholder={hud === 'signal' ? 'Add signal — filter by name…' : 'Add node — filter operations…'}
                value={hudFilter}
                onInput={(e) => setHudFilter((e.target as HTMLInputElement).value)}
                onKeyDown={(e) => {
                  if (e.key === 'Escape') { e.preventDefault(); closeHud(); return; }
                  if (e.key === 'Enter') {
                    e.preventDefault();
                    if (hud === 'signal') {
                      const opt = filteredSignalOptions()[0];
                      if (opt) { addSourceWith(opt.ds, opt.name); closeHud(); }
                    } else {
                      const op = filteredOps()[0];
                      if (op) { addOp(op); closeHud(); }
                    }
                  }
                }} />
              <div class="synth-hud-list">
                {hud === 'signal'
                  ? filteredSignalOptions().slice(0, 50).map(opt => (
                      <button key={`${opt.ds}:${opt.name}`} class="synth-hud-item"
                        onClick={() => { addSourceWith(opt.ds, opt.name); closeHud(); }}>
                        <span class="synth-hud-item-name">{opt.name}</span>
                        <span class="synth-hud-item-meta">{opt.ds}</span>
                      </button>
                    ))
                  : filteredOps().map(op => (
                      <button key={op.type} class="synth-hud-item"
                        onClick={() => { addOp(op); closeHud(); }}>
                        <span class="synth-hud-item-name">{op.label}</span>
                        <span class="synth-hud-item-meta">{op.type}</span>
                      </button>
                    ))}
                {hud === 'signal' && filteredSignalOptions().length === 0 && (
                  <div class="hint synth-hud-empty">No matching signals.</div>
                )}
              </div>
            </div>
          </div>
        )}

        {!create && showVersions && (
          <div class="ver-pane synth-ver-pane">
            <div class="ver-pane-head">Version history
              <button class="icon-btn" onClick={() => setShowVersions(false)}>✕</button>
            </div>
            <VersionTree
              base={`/api/v1/synthetic/${encodeURIComponent(sigName)}`}
              viewing={viewingVersion}
              dirty={canUndo}
              reloadKey={verReload}
              onView={viewVersion}
              onForked={() => { onSaved(); setVerReload(k => k + 1); }}
              onPromoted={async () => { await reloadCurrent(); onSaved(); setVerReload(k => k + 1); }}
              onCompare={(av, bv) => setDiff({ av, bv })}
              onError={setError} />
          </div>
        )}

        {diff && (
          <DiffViewer base={`/api/v1/synthetic/${encodeURIComponent(sigName)}`}
            av={diff.av} bv={diff.bv} title={`${sigName} — v${diff.av} → v${diff.bv}`}
            onClose={() => setDiff(null)} />
        )}

        <div class="wizard-footer">
          {error && <span class="wizard-error" style="flex:1;">{error}</span>}
          {!error && firstError && <span class="hint" style="flex:1;">{firstError}</span>}
          <button class="toolbar-btn" onClick={onClose}>Cancel</button>
          <button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!firstError}>
            {saving ? 'Saving…' : 'Save Signal'}
          </button>
        </div>
      </div>
    </div>
  );
}
