import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect';
import LuaEditor from './LuaEditor';
import type { InputRef, PipelineNode, SignalDef } from './lib/types';

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

interface Props {
  name: string;
  onClose: () => void;
  onSaved: () => void;
}

// ── Node-type catalogue ──────────────────────────────────────────────────────
// Mirrors the backend dsp node set (internal/dsp/nodes.go). The combine nodes
// (add/subtract/…) and expr take several inputs, which only the FIRST node of a
// pipeline receives — see the compile() note below.
interface NodeParam { label: string; key: string; type: 'number' | 'text' | 'lua'; default: string; }
interface OpDef { type: string; label: string; params: NodeParam[]; }

const OPS: OpDef[] = [
  { type: 'gain',           label: 'Gain',           params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
  { type: 'offset',         label: 'Offset',         params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
  { type: 'add',            label: 'Add (Σ inputs)', params: [] },
  { type: 'subtract',       label: 'Subtract (a−b)', params: [] },
  { type: 'multiply',       label: 'Multiply (Π)',   params: [] },
  { type: 'divide',         label: 'Divide (a÷b)',   params: [] },
  { type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
  { type: 'rms',            label: 'RMS',            params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
  { type: 'lowpass',        label: 'Low-pass',       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',     params: [] },
  { type: 'integrate',      label: 'Integrate',      params: [] },
  { type: 'clamp',          label: 'Clamp',          params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
  { type: 'threshold',      label: 'Threshold',      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',        params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
  { type: 'lua',            label: 'Lua Script',     params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
];
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 ?? []; }

// ── Graph model (UI only — compiled to SignalDef 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
}
interface GWire { from: string; to: string; }
interface Graph { nodes: GNode[]; wires: GWire[]; }

// ── 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;
const PORT_TOP = 1.4 * REM;
const PORT_R = 0.375 * REM;

function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); }
function hasInput(k: NodeKind): boolean { return k !== 'source'; }
function hasOutput(k: NodeKind): boolean { return k !== 'output'; }
function inAnchor(n: GNode) { return { x: n.x, y: n.y + PORT_TOP }; }
function outAnchor(n: GNode) { return { x: n.x + NODE_W, y: n.y + PORT_TOP }; }
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}`;
}

// Build an initial graph by laying out an existing SignalDef: sources stacked on
// the left, the linear pipeline as a row of op nodes, output on the right.
function buildInitial(def: SignalDef): Graph {
  const inputs: InputRef[] = 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();
    nodes.push({ id, kind: 'op', x: (15 + i * 12) * REM, y: 3 * REM, op: nd.type, params: { ...nd.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 => wires.push({ from: s, to: headId }));
  for (let i = 0; i < opIds.length - 1; i++) wires.push({ from: opIds[i], to: opIds[i + 1] });
  if (opIds.length > 0) wires.push({ from: opIds[opIds.length - 1], to: outId });

  return { nodes, wires };
}

// Compile the visual graph back into the backend's linear form. The pipeline is
// a single chain ending at the output; the head node receives every source
// signal (inputs[0]=a, inputs[1]=b, …), every later node the previous output.
function compile(g: Graph): { inputs?: InputRef[]; pipeline?: PipelineNode[]; error?: string } {
  const output = g.nodes.find(n => n.kind === 'output');
  if (!output) return { error: 'missing output node' };
  const byId = new Map(g.nodes.map(n => [n.id, n]));
  const upstream = (id: string) => g.wires.filter(w => w.to === id).map(w => byId.get(w.from)).filter(Boolean) as GNode[];

  const chain: GNode[] = [];
  const seen = new Set<string>();
  let cur: GNode = output;
  for (;;) {
    if (seen.has(cur.id)) return { error: 'the graph contains a cycle' };
    seen.add(cur.id);
    const ups = upstream(cur.id);
    const opUps = ups.filter(n => n.kind === 'op');
    const srcUps = ups.filter(n => n.kind === 'source');
    if (opUps.length > 1) return { error: 'a node may have only one upstream node — the pipeline must be a single chain' };
    if (opUps.length === 1) {
      if (srcUps.length > 0) return { error: 'only the first processing node may take input signals directly' };
      chain.unshift(opUps[0]);
      cur = opUps[0];
      continue;
    }
    // Reached the head of the chain: its source upstreams become the inputs.
    if (srcUps.length === 0) {
      return { error: cur.kind === 'output' ? 'connect at least one signal to the output' : 'the first node has no input signals' };
    }
    const inputs = srcUps
      .slice()
      .sort((a, b) => a.y - b.y)
      .map(n => ({ ds: n.ds || '', signal: n.signal || '' }));
    if (inputs.some(i => !i.ds || !i.signal)) return { error: 'every input signal needs a data source and a signal' };
    const pipeline = chain.map(n => ({ type: n.op!, params: n.params ?? {} }));
    return { inputs, pipeline };
  }
}

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, 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);
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState('');

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

  const canvasRef = useRef<HTMLDivElement>(null);
  const dragNode = useRef<{ id: string; dx: number; dy: 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 [, 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.map(s => s.name) }));
    } catch {}
  }

  useEffect(() => {
    fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`)
      .then(r => r.ok ? r.json() : Promise.reject(r.statusText))
      .then((d: SignalDef) => {
        setDef(d);
        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]);

  // ── History ────────────────────────────────────────────────────────────────
  function pushUndo() {
    undoStack.current = [...undoStack.current.slice(-49), graphRef.current];
    redoStack.current = [];
  }
  function commit(next: Graph, record = true) {
    if (record) pushUndo();
    setGraph(next);
  }
  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);
    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);
    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);
  }
  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;
    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,
    });
  }
  function moveNode(id: string, x: number, y: number, record: boolean) {
    commit({ nodes: graph.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: graph.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
    commit({ nodes: graph.nodes.filter(x => x.id !== id), wires: graph.wires.filter(w => w.from !== id && w.to !== id) });
    if (selected === id) setSelected(null);
  }
  function addWire(from: string, to: string) {
    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) || !hasInput(tn.kind)) return;
    if (graph.wires.some(w => w.from === from && w.to === to)) return;
    commit({ nodes: graph.nodes, wires: [...graph.wires, { from, to }] });
  }
  function deleteWire(idx: number) {
    commit({ nodes: graph.nodes, wires: graph.wires.filter((_, i) => i !== idx) });
    if (selectedWire === idx) setSelectedWire(null);
  }

  // ── Pointer / drag / wire ──────────────────────────────────────────────────
  function toCanvas(e: MouseEvent) {
    const el = canvasRef.current!;
    const rect = el.getBoundingClientRect();
    return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop };
  }
  function startNodeDrag(e: MouseEvent, node: GNode) {
    e.stopPropagation();
    const p = toCanvas(e);
    dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false };
    setSelected(node.id); setSelectedWire(null);
    window.addEventListener('mousemove', onNodeDragMove);
    window.addEventListener('mouseup', onNodeDragUp);
  }
  function onNodeDragMove(e: MouseEvent) {
    const d = dragNode.current;
    if (!d) return;
    const p = toCanvas(e);
    const record = !d.pushed;
    d.pushed = true;
    moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record);
  }
  function onNodeDragUp() {
    dragNode.current = null;
    window.removeEventListener('mousemove', onNodeDragMove);
    window.removeEventListener('mouseup', onNodeDragUp);
  }
  function startWire(e: MouseEvent, node: GNode) {
    e.stopPropagation();
    const p = toCanvas(e);
    setPendingWire({ from: node.id, x: p.x, y: p.y });
    window.addEventListener('mousemove', onWireMove);
    window.addEventListener('mouseup', onWireUp);
  }
  function onWireMove(e: MouseEvent) {
    const cur = pendingRef.current;
    if (!cur) return;
    const p = toCanvas(e);
    setPendingWire({ ...cur, x: p.x, y: p.y });
  }
  function endWire() {
    pendingRef.current = null;
    window.removeEventListener('mousemove', onWireMove);
    window.removeEventListener('mouseup', onWireUp);
    setPendingWire(null);
  }
  function onWireUp() { endWire(); }
  function finishWire(target: GNode) {
    const cur = pendingRef.current;
    if (cur && hasInput(target.kind)) addWire(cur.from, target.id);
    endWire();
  }

  // ── 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 (e.key !== 'Delete' && e.key !== 'Backspace') return;
      if (selectedWire !== null) deleteWire(selectedWire);
      else if (selected) deleteNode(selected);
    }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [selected, selectedWire, graph]);

  const byId = new Map(graph.nodes.map(n => [n.id, n]));
  const sel = graph.nodes.find(n => n.id === selected) ?? null;
  const compiled = compile(graph);

  async function handleSave() {
    if (!def) return;
    if (compiled.error) { setError(compiled.error); return; }
    setSaving(true);
    setError('');
    try {
      const body = { ...def, inputs: compiled.inputs, pipeline: compiled.pipeline, ds: undefined, signal: undefined };
      const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, {
        method: '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);
    }
  }

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

  return (
    <div class="wizard-backdrop" onClick={onClose}>
      <div class="synth-graph" onClick={(e) => e.stopPropagation()}>
        <div class="wizard-header">
          <span>Synthetic Signal — {name}</span>
          <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>
              </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 input signals into the first operation, chain operations, and connect the
                last one to <b>Output</b>. The first node receives every input as <code>a,b,c,d</code>.
              </div>
            </div>

            <div class="flow-canvas" ref={canvasRef}
              onMouseDown={() => { setSelected(null); setSelectedWire(null); }}
              onDragOver={onCanvasDragOver}
              onDrop={onCanvasDrop}>
              <div class="flow-canvas-inner">
                <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;
                    const p1 = outAnchor(a); const p2 = inAnchor(b);
                    return (
                      <path key={idx}
                        class={`flow-wire${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.map(node => (
                  <div key={node.id}
                    class={nodeClass(node)}
                    style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px;`}
                    onMouseDown={(e) => startNodeDrag(e, node)}
                    onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
                    <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>

                    {hasInput(node.kind) && (
                      <div class="flow-port flow-port-in" title="Input"
                        style={`top:${PORT_TOP - PORT_R}px; left:${-PORT_R}px;`}
                        onMouseDown={(e) => e.stopPropagation()}
                        onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} />
                    )}
                    {hasOutput(node.kind) && (
                      <div class="flow-port flow-port-out" title="Output"
                        style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
                        onMouseDown={(e) => startWire(e, node)} />
                    )}
                  </div>
                ))}
              </div>
            </div>

            <div class="flow-inspector">
              {!sel && <div class="hint">Select a node to edit it, or add one from the palette.</div>}

              {sel?.kind === 'source' && (
                <Fragment>
                  <div class="wizard-section-title">Input signal</div>
                  <div class="wizard-field">
                    <label>Data source</label>
                    <SearchableSelect value={sel.ds ?? ''} options={dataSources}
                      onSelect={(ds) => { loadSignals(ds); patchNode(sel.id, { ds, signal: '' }); }} />
                  </div>
                  <div class="wizard-field">
                    <label>Signal</label>
                    <SearchableSelect value={sel.signal ?? ''} options={dsSignals[sel.ds ?? ''] ?? []}
                      onSelect={(signal) => patchNode(sel.id, { signal })}
                      placeholder={sel.ds ? 'Search signals…' : 'Select data source first'} />
                  </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>
                  {opParamDefs(sel.op ?? '').length === 0 && (
                    <p class="hint">No parameters — this operation transforms its input 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>
        )}

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