import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SignalPicker, { SignalOption } from './SignalPicker';
import { checkExpr } from './lib/expr';
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types';
import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup, type Rect } from './lib/nodeGroups';
import { useFlowZoom, type Box } from './lib/flowZoom';
import { LogicEngine } from './lib/logic';
import { useFlowDebug, formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug';

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

// Split a "ds:name" reference on the FIRST ':' only (EPICS PV names contain ':').
function splitRef(ref: string): { ds: string; name: string } {
  const i = ref.indexOf(':');
  if (i < 0) return { ds: '', name: '' };
  return { ds: ref.slice(0, i), name: ref.slice(i + 1) };
}

interface Props {
  graph: LogicGraph;
  onChange: (graph: LogicGraph) => void;
  // The panel's widgets, offered as targets for the Widget-control action.
  widgets?: Widget[];
  // Panel-local state variables, offered as quick write targets / signals.
  statevars?: StateVar[];
  // Edit local state variables directly from the logic editor (the layout-side
  // SignalTree is hidden while the logic tab is open).
  onStateVarsChange?: (vars: StateVar[]) => void;
}

// Visual geometry of a node block. Expressed in rem (relative to the root font
// size) so the whole flow editor scales coherently on high-DPI screens / when
// the base font is enlarged for accessibility, instead of staying pixel-tiny.
const REM = (() => {
  if (typeof document === 'undefined') return 16;
  return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
})();
const NODE_W = 11.5 * REM;  // node width
const CANVAS_W = 125 * REM; // logical canvas size (matches .flow-canvas-inner)
const CANVAS_H = 87.5 * REM;
const PORT_TOP = 2 * REM;   // y of the first port row, relative to node top
const PORT_GAP = 1.375 * REM; // vertical spacing between stacked output ports
const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot)
// Ports sit inside the node's padding box, offset from the node border-box origin
// by the node borders (3px colored top accent, 1px sides). Anchors add the same
// offsets so wires meet the port centers, not their top edges.
const BORDER = 1;
const BORDER_TOP = 3;
// Group frame geometry.
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

interface PaletteEntry {
  kind: LogicNodeKind;
  label: string;
  params: Record<string, string>;
}

const PALETTE: PaletteEntry[] = [
  { kind: 'trigger.button',    label: 'Button',     params: { name: 'action' } },
  { kind: 'trigger.threshold', label: 'Threshold',  params: { signal: '', op: '>', value: '0' } },
  { kind: 'trigger.change',    label: 'On change',  params: { signal: '' } },
  { kind: 'trigger.timer',     label: 'Timer',      params: { interval: '1000' } },
  { kind: 'trigger.loop',      label: 'Panel loop', params: { interval: '1000' } },
  { kind: 'trigger.start',     label: 'On open',    params: {} },
  { kind: 'trigger.stop',      label: 'On close',   params: {} },
  { kind: 'gate.and',          label: 'AND gate',   params: {} },
  { kind: 'flow.if',           label: 'If / else',  params: { cond: '' } },
  { kind: 'flow.loop',         label: 'Loop',       params: { mode: 'count', count: '3', cond: '' } },
  { kind: 'action.write',      label: 'Write',      params: { target: '', expr: '' } },
  { kind: 'action.delay',      label: 'Delay',      params: { ms: '500' } },
  { kind: 'action.accumulate', label: 'Accumulate', params: { array: 'data', expr: '' } },
  { kind: 'action.export',     label: 'Export CSV', params: { columns: '[{"array":"data","label":""}]', align: 'common', filename: '' } },
  { kind: 'action.clear',      label: 'Clear array', params: { array: 'data' } },
  { kind: 'action.log',        label: 'Log',        params: { expr: '', label: '' } },
  { kind: 'action.widget',     label: 'Widget control', params: { widget: '', op: 'disable' } },
  { kind: 'action.dialog.info',     label: 'Info dialog',      params: { title: 'Info', message: '' } },
  { kind: 'action.dialog.error',    label: 'Error dialog',     params: { title: 'Error', message: '' } },
  { kind: 'action.dialog.setpoint', label: 'Set-point dialog', params: { title: 'Set value', message: '', fields: '[{"type":"input","label":"","target":"","default":""}]' } },
  { kind: 'action.config.apply', label: 'Apply config', params: { instance: '', instanceSource: 'fixed', instanceVar: '' } },
  { kind: 'action.config.read',  label: 'Read config',  params: { instance: '', instanceSource: 'fixed', instanceVar: '', key: '', target: '' } },
  { kind: 'action.config.write', label: 'Write config', params: { instance: '', instanceSource: 'fixed', instanceVar: '', key: '', expr: '' } },
  { kind: 'action.config.create', label: 'Create config', params: { set: '', name: 'auto', from: '', target: '' } },
  { kind: 'action.config.snapshot', label: 'Snapshot config', params: { set: '', name: '', target: '' } },
];

const KIND_LABEL: Record<LogicNodeKind, string> = {
  'trigger.button': 'Button',
  'trigger.threshold': 'Threshold',
  'trigger.change': 'On change',
  'trigger.timer': 'Timer',
  'trigger.loop': 'Panel loop',
  'trigger.start': 'On open',
  'trigger.stop': 'On close',
  'gate.and': 'AND gate',
  'flow.if': 'If / else',
  'flow.loop': 'Loop',
  'action.write': 'Write',
  'action.delay': 'Delay',
  'action.accumulate': 'Accumulate',
  'action.export': 'Export CSV',
  'action.clear': 'Clear array',
  'action.log': 'Log',
  'action.widget': 'Widget control',
  'action.dialog.info': 'Info dialog',
  'action.dialog.error': 'Error dialog',
  'action.dialog.setpoint': 'Set-point dialog',
  'action.config.apply': 'Apply config',
  'action.config.read': 'Read config',
  'action.config.write': 'Write config',
  'action.config.create': 'Create config',
  'action.config.snapshot': 'Snapshot config',
};

const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;

function isTrigger(kind: LogicNodeKind): boolean { return kind.startsWith('trigger.'); }
function category(kind: LogicNodeKind): 'trigger' | 'gate' | 'flow' | 'action' {
  if (kind.startsWith('trigger.')) return 'trigger';
  if (kind.startsWith('gate.')) return 'gate';
  if (kind.startsWith('flow.')) return 'flow';
  return 'action';
}
function hasInput(kind: LogicNodeKind): boolean { return !isTrigger(kind); }

interface Port { id: string; label: string; }
function outputs(kind: LogicNodeKind): Port[] {
  if (kind === 'flow.if') return [{ id: 'then', label: 'then' }, { id: 'else', label: 'else' }];
  if (kind === 'flow.loop') return [{ id: 'body', label: 'body' }, { id: 'done', label: 'done' }];
  return [{ id: 'out', label: '' }];
}
function nodeHeight(kind: LogicNodeKind): number {
  return PORT_TOP + outputs(kind).length * PORT_GAP;
}

function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }

// Port anchors in canvas coordinates.
function inAnchor(n: LogicNode) { return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
function outAnchor(n: LogicNode, port: string) {
  const ports = outputs(n.kind);
  const i = Math.max(0, ports.findIndex(p => p.id === port));
  return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP + i * 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}`;
}

export default function LogicEditor({ graph, onChange, widgets = [], statevars, onStateVarsChange }: Props) {
  const nodes = graph.nodes;
  const wires = graph.wires;

  const [selectedNode, setSelectedNode] = useState<string | null>(null);
  const [selectedWire, setSelectedWire] = useState<number | null>(null);
  // Multi-selection (Shift+click) used to build groups; selectedNode stays the
  // "primary" node shown in the inspector.
  const [selSet, setSelSet] = useState<Set<string>>(new Set());
  const [selectedGroup, setSelectedGroup] = useState<string | null>(null);

  const groups = graph.groups ?? [];

  const [dataSources, setDataSources] = useState<string[]>([]);
  const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
  const [configInstances, setConfigInstances] = useState<{ id: string; name: string }[]>([]);
  const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);

  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; port: string; x: number; y: number } | null>(null);
  const pendingRef = useRef(pendingWire);
  pendingRef.current = pendingWire;

  // Undo/redo history + clipboard, scoped to the logic graph (separate from
  // EditMode's widget/layout history). `graphRef` always holds the latest graph
  // so the ref-based stacks read a stable value across renders.
  const graphRef = useRef(graph);
  graphRef.current = graph;
  const undoStack = useRef<LogicGraph[]>([]);
  const redoStack = useRef<LogicGraph[]>([]);
  const clipboard = useRef<{ nodes: LogicNode[]; wires: LogicWire[] } | null>(null);
  const [, setHistTick] = useState(0); // re-render so toolbar enabled-state updates
  const bump = () => setHistTick(t => t + 1);
  const canUndo = undoStack.current.length > 0;
  const canRedo = redoStack.current.length > 0;

  // Live / debug mode: a throwaway dry-run LogicEngine evaluates the edited graph
  // against live signals with all side effects (writes, config, dialogs)
  // suppressed, so each node lights with its value without touching production.
  const [debug, setDebug] = useState(false);
  const dbgEngine = useRef<LogicEngine | null>(null);

  useEffect(() => {
    fetch('/api/v1/datasources')
      .then(r => r.ok ? r.json() : [])
      .then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
      .catch(() => {});
    fetch('/api/v1/config/instances')
      .then(r => r.ok ? r.json() : [])
      .then((list: { id: string; name: string }[]) => setConfigInstances(list ?? []))
      .catch(() => {});
    fetch('/api/v1/config/sets')
      .then(r => r.ok ? r.json() : [])
      .then((list: { id: string; name: string }[]) => setConfigSets(list ?? []))
      .catch(() => {});
  }, []);

  async function loadSignals(ds: string) {
    if (!ds || ds === 'local' || ds === 'sys' || 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 {}
  }

  // 'sys' exposes the engine's built-in time signals ({sys:time}, {sys:dt}).
  const dsOptions = ['local', 'sys', ...dataSources];
  function signalOptions(ds: string): string[] {
    if (ds === 'local') return (statevars ?? []).map(v => v.name);
    if (ds === 'sys') return ['time', 'dt'];
    return dsSignals[ds] ?? [];
  }

  // 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 dsOptions) for (const name of signalOptions(ds)) out.push({ ds, name });
    return out;
  }
  function openAllSignals() { dsOptions.forEach(ds => loadSignals(ds)); }

  // The config-instance selector shared by the apply/read/write nodes. The
  // instance can be fixed (chosen here at design time) or read at run time from
  // a panel variable (instanceSource='var') — this is how a config-selector
  // widget feeds these nodes the operator's current choice.
  function instanceField(node: LogicNode) {
    const src = node.params.instanceSource ?? 'fixed';
    return (
      <Fragment>
        <div class="wizard-field">
          <label>Instance source</label>
          <select class="prop-select" value={src}
            onChange={(e) => patchParams(node.id, { instanceSource: (e.target as HTMLSelectElement).value })}>
            <option value="fixed">Fixed instance</option>
            <option value="var">From variable</option>
          </select>
        </div>
        {src === 'var' ? (
          <div class="wizard-field">
            <label>Instance-id variable</label>
            <SignalPicker value={node.params.instanceVar ?? ''} options={allSignalOptions()}
              onOpen={openAllSignals} allowFreeform
              onChange={(ref) => patchParams(node.id, { instanceVar: ref })} />
            <p class="hint">Reads the instance id (a string) from this panel variable — e.g. the
              output of a Config selector widget.</p>
          </div>
        ) : (
          <div class="wizard-field">
            <label>Config instance</label>
            <select class="prop-select" value={node.params.instance ?? ''}
              onChange={(e) => patchParams(node.id, { instance: (e.target as HTMLSelectElement).value })}>
              <option value="">choose…</option>
              {configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
            </select>
          </div>
        )}
      </Fragment>
    );
  }

  const selected = nodes.find(n => n.id === selectedNode) ?? null;

  useEffect(() => {
    if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change') {
      loadSignals(splitRef(selected.params.signal ?? '').ds);
    }
    if (selected?.kind === 'action.write') {
      loadSignals(splitRef(selected.params.target ?? '').ds);
    }
    if (selected && selected.kind.startsWith('action.dialog.')) {
      for (const f of parseDialogFields(selected)) {
        if (f.type === 'input') loadSignals(splitRef(f.target ?? '').ds);
      }
    }
  }, [selectedNode, selected?.kind]);

  // ── History ────────────────────────────────────────────────────────────────
  // Push the current graph onto the undo stack (clearing redo). Call right
  // before a discrete edit; for continuous gestures (node drag) call once.
  function pushUndo() {
    undoStack.current = [...undoStack.current.slice(-49), graphRef.current];
    redoStack.current = [];
    bump();
  }
  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);
    setSelectedNode(null);
    setSelectedWire(null);
    setSelectedGroup(null);
    setSelSet(new Set());
    onChange(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);
    setSelectedNode(null);
    setSelectedWire(null);
    onChange(next);
    bump();
  }

  // ── Graph mutators ─────────────────────────────────────────────────────────
  // Apply a new graph, recording an undo entry unless `record` is false (used
  // for the intermediate frames of a node drag, which record once on first move).
  function setGraph(next: LogicGraph, record = true) {
    if (record) pushUndo();
    onChange(next);
  }
  function setNodes(next: LogicNode[], record = true) { setGraph({ nodes: next, wires, ...(groups.length ? { groups } : {}) }, record); }
  function setWires(next: LogicWire[]) { setGraph({ nodes, wires: next, ...(groups.length ? { groups } : {}) }); }
  function setGroups(next: NodeGroup[], record = true) { setGraph({ nodes, wires, groups: next }, record); }

  function addNode(entry: PaletteEntry, x?: number, y?: number) {
    const node: LogicNode = {
      id: genId(),
      kind: entry.kind,
      x: x ?? 40 + (nodes.length % 5) * 30,
      y: y ?? 40 + (nodes.length % 5) * 30,
      params: { ...entry.params },
    };
    setGraph({ nodes: [...nodes, node], wires, ...(groups.length ? { groups } : {}) });
    setSelectedNode(node.id);
    setSelectedWire(null);
    setSelSet(new Set([node.id]));
    setSelectedGroup(null);
  }

  function patchParams(id: string, patch: Record<string, string>) {
    setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n)));
  }
  // Move several nodes at once (group / multi-select drag). `pos` maps id→{x,y}.
  function moveNodes(pos: Map<string, { x: number; y: number }>, record = false) {
    setNodes(nodes.map(n => (pos.has(n.id) ? { ...n, ...pos.get(n.id)! } : n)), record);
  }

  // ── Groups ──────────────────────────────────────────────────────────────────
  function groupSelection() {
    if (selSet.size < 1) return;
    const members = nodes.filter(n => selSet.has(n.id)).map(n => n.id);
    if (members.length < 1) return;
    const g: NodeGroup = { id: genGroupId(), label: 'Group', members, collapsed: false };
    setGroups([...groups, g]);
    setSelectedGroup(g.id);
    setSelectedNode(null);
    setSelectedWire(null);
  }
  function ungroup(gid: string) {
    setGroups(groups.filter(g => g.id !== gid));
    if (selectedGroup === gid) setSelectedGroup(null);
  }
  function toggleCollapse(gid: string) {
    setGroups(groups.map(g => (g.id === gid ? { ...g, collapsed: !g.collapsed } : g)));
  }
  function renameGroup(gid: string, label: string) {
    setGroups(groups.map(g => (g.id === gid ? { ...g, label } : g)), false);
  }
  function deleteNode(id: string) {
    const nextNodes = nodes.filter(n => n.id !== id);
    const nextGroups = pruneGroups(groups, new Set(nextNodes.map(n => n.id)));
    setGraph({
      nodes: nextNodes,
      wires: wires.filter(w => w.from !== id && w.to !== id),
      ...(nextGroups.length ? { groups: nextGroups } : {}),
    });
    if (selectedNode === id) setSelectedNode(null);
    if (selSet.has(id)) { const s = new Set(selSet); s.delete(id); setSelSet(s); }
  }
  function addWire(from: string, port: string, to: string) {
    if (from === to) return;
    if (wires.some(w => w.from === from && (w.fromPort ?? 'out') === port && w.to === to)) return;
    setWires([...wires, { from, to, ...(port !== 'out' ? { fromPort: port } : {}) }]);
  }
  function deleteWire(idx: number) {
    setWires(wires.filter((_, i) => i !== idx));
    if (selectedWire === idx) setSelectedWire(null);
  }

  // ── Clipboard ────────────────────────────────────────────────────────────
  // Copy the selected node (params deep-cloned). Single-selection only, but the
  // paste remap is written generically so it extends to multi-select.
  function copySelection() {
    const n = graphRef.current.nodes.find(x => x.id === selectedNode);
    if (!n) return;
    clipboard.current = { nodes: [{ ...n, params: { ...n.params } }], wires: [] };
  }
  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 + 30, y: n.y + 30, params: { ...n.params } };
    });
    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)! }));
    setGraph({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires], ...((cur.groups?.length) ? { groups: cur.groups } : {}) });
    if (newNodes.length === 1) { setSelectedNode(newNodes[0].id); setSelectedWire(null); }
  }

  // ── Pointer geometry ───────────────────────────────────────────────────────
  function toCanvas(e: MouseEvent): { x: number; y: number } {
    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 };
  }

  // ── Node dragging ──────────────────────────────────────────────────────────
  // 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 = 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 };
    window.addEventListener('mousemove', onNodeDragMove);
    window.addEventListener('mouseup', onNodeDragUp);
  }
  function startNodeDrag(e: MouseEvent, node: LogicNode) {
    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);
      setSelectedNode(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]));
    setSelectedNode(node.id);
    beginDrag(e, ids);
  }
  function onNodeDragMove(e: MouseEvent) {
    const d = dragNode.current;
    if (!d) return;
    const p = toCanvas(e);
    // Record a single undo entry per drag (on the first move, so a plain
    // click-to-select doesn't create a spurious history step).
    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);
  }
  function onNodeDragUp() {
    dragNode.current = null;
    window.removeEventListener('mousemove', onNodeDragMove);
    window.removeEventListener('mouseup', onNodeDragUp);
  }

  // ── Wire drawing ───────────────────────────────────────────────────────────
  function startWire(e: MouseEvent, node: LogicNode, port: string) {
    e.stopPropagation();
    const p = toCanvas(e);
    setPendingWire({ from: node.id, port, 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 });
  }
  // Tear down an in-progress wire drag. Called both on a plain release over
  // empty canvas (window mouseup) and after a connection is made — the latter
  // is essential because the target port's mouseup calls stopPropagation, so
  // the window listener never fires and would otherwise leave a phantom wire
  // following the cursor.
  function endWire() {
    pendingRef.current = null;
    window.removeEventListener('mousemove', onWireMove);
    window.removeEventListener('mouseup', onWireUp);
    setPendingWire(null);
  }
  function onWireUp() {
    endWire();
  }
  function finishWire(target: LogicNode) {
    const cur = pendingRef.current;
    if (cur && hasInput(target.kind)) addWire(cur.from, cur.port, target.id);
    endWire();
  }

  // ── Palette drag-and-drop ────────────────────────────────────────────────
  const DRAG_MIME = 'application/x-uopi-logic-node';
  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 kind = e.dataTransfer?.getData(DRAG_MIME);
    if (!kind) return;
    e.preventDefault();
    const entry = PALETTE.find(p => p.kind === kind);
    if (!entry) return;
    const p = toCanvas(e);
    // Drop point becomes the node's top-left, offset so the cursor lands inside.
    addNode(entry, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - PORT_TOP / 2));
  }

  // ── Keyboard shortcuts (delete / undo / redo / copy / paste) ────────────────
  useEffect(() => {
    function onKey(e: KeyboardEvent) {
      const t = e.target as Element;
      // While typing in a field, leave native text editing shortcuts alone.
      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; }
      // 'g' groups the current multi-selection (Ctrl+G or plain g).
      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 (selectedNode) deleteNode(selectedNode);
    }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [selectedNode, selectedWire, selectedGroup, selSet, nodes, wires, groups]);

  const byId = new Map(nodes.map(n => [n.id, n]));

  // Array names already referenced by accumulate/export/clear nodes, offered as
  // autocomplete suggestions so the same array is reused across nodes.
  const arrayNames = Array.from(new Set(
    nodes.map(n => n.params.array).filter((a): a is string => !!a)
  ));

  // Append a {ds:name} reference into a node's expression field.
  function insertRef(id: string, field: string, ds: string, sig: string) {
    const cur = (byId.get(id)?.params[field]) ?? '';
    patchParams(id, { [field]: `${cur}{${ds}:${sig}}` });
  }

  // ── 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: nodeHeight(n.kind) } : 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 hidden = 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) hidden.add(m);
  }
  const collapsedBoxOf = (id: string): Rect | null => {
    const g = groupContaining(groups, id);
    return g && g.collapsed ? (boxByGroup.get(g.id) ?? null) : null;
  };

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

  // Create/tear down the dry-run engine as debug mode toggles.
  useEffect(() => {
    if (!debug) return;
    const eng = new LogicEngine();
    eng.setDryRun(true);
    eng.load(graphRef.current);
    dbgEngine.current = eng;
    return () => { eng.clear(); dbgEngine.current = null; };
  }, [debug]);
  // Reload the dry-run engine when the edited graph changes (debounced so rapid
  // edits settle before re-running triggers).
  useEffect(() => {
    if (!debug || !dbgEngine.current) return;
    const h = setTimeout(() => dbgEngine.current?.load(graphRef.current), 300);
    return () => clearTimeout(h);
  }, [debug, graph]);
  // Poll the dry-run engine's per-node state into the shared overlay.
  const debugSnap: DebugSnapshot = useFlowDebug(debug, async () => {
    const eng = dbgEngine.current;
    if (!eng) return null;
    const snap: DebugSnapshot = new Map();
    for (const [id, s] of eng.getDebug()) snap.set(id, { value: s.value, active: s.active });
    return snap;
  });

  const resolveOut = (n: LogicNode, port: string) => {
    const box = collapsedBoxOf(n.id);
    return box ? { x: box.x + box.w, y: box.y + box.h / 2 } : outAnchor(n, port);
  };
  const resolveIn = (n: LogicNode) => {
    const box = collapsedBoxOf(n.id);
    return box ? { x: box.x, y: box.y + box.h / 2 } : inAnchor(n);
  };
  // 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;
  };

  return (
    <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 — dry-run the flow and show each node's value (no real writes)">◉</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">Triggers</div>
        {PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
        <div class="flow-palette-title">Logic</div>
        {PALETTE.filter(e => category(e.kind) === 'gate' || category(e.kind) === 'flow').map(paletteBtn)}
        <div class="flow-palette-title">Actions</div>
        {PALETTE.filter(e => category(e.kind) === 'action').map(paletteBtn)}
        <div class="flow-palette-hint hint">
          Triggers start a flow. Drag a node's right port to another node's left port to connect.
          In expressions, reference signals as <code>{'{ds:name}'}</code> and local vars by name.
        </div>
        {onStateVarsChange && (
          <LocalVars statevars={statevars ?? []} onChange={onStateVarsChange} />
        )}
        <datalist id="flow-array-names">
          {arrayNames.map(a => <option key={a} value={a} />)}
        </datalist>
      </div>

      <div class="flow-canvas" ref={canvasRef}
        onMouseDown={() => { setSelectedNode(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 sel = selectedGroup === g.id;
            if (g.collapsed && box) {
              return (
                <div key={g.id}
                  class={`flow-group-box${sel ? ' 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); setSelectedNode(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${sel ? ' 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); setSelectedNode(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">
            {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, w.fromPort ?? 'out');
              const p2 = resolveIn(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); setSelectedNode(null); }} />
              );
            })}
            {pendingWire && (() => {
              const a = byId.get(pendingWire.from);
              if (!a) return null;
              const p1 = outAnchor(a, pendingWire.port);
              return <path class="flow-wire flow-wire-pending" d={wirePathStr(p1.x, p1.y, pendingWire.x, pendingWire.y)} />;
            })()}
          </svg>

          {nodes.filter(n => !hidden.has(n.id)).map(node => {
            const dbg = debug ? debugSnap.get(node.id) : undefined;
            const firable = debug && node.kind.startsWith('trigger.');
            const grouped = groupContaining(groups, node.id) ? ' flow-node-grouped' : '';
            return (
            <div key={node.id}
              class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}${selSet.has(node.id) && selSet.size > 1 ? ' flow-node-multi' : ''}${dbg ? ' ' + nodeDebugClass(dbg) : ''}${firable ? ' flow-node-firable' : ''}${grouped}`}
              style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeHeight(node.kind)}px;`}
              title={firable ? 'Double-click to force this trigger to fire' : undefined}
              onMouseDown={(e) => startNodeDrag(e, node)}
              onDblClick={() => { if (firable) dbgEngine.current?.fireTrigger(node.id); }}
              onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
              {dbg && <span class={`flow-node-badge${dbg.approx ? ' approx' : ''}${dbg.error ? ' error' : ''}`} title={badgeTitle(dbg)}>{(dbg.approx ? '~' : '') + formatBadge(dbg)}</span>}
              <div class="flow-node-header">
                <span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
                <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); }} />
              )}
              {outputs(node.kind).map((port, i) => (
                <Fragment key={port.id}>
                  {port.label && (
                    <span class="flow-port-label" style={`top:${PORT_TOP + i * PORT_GAP - 0.5 * REM}px;`}>{port.label}</span>
                  )}
                  <div class={`flow-port flow-port-out flow-port-${port.id}`}
                    style={`top:${PORT_TOP + i * PORT_GAP - PORT_R}px; right:${-PORT_R}px;`}
                    title={`Output: ${port.id}`}
                    onMouseDown={(e) => startWire(e, node, port.id)} />
                </Fragment>
              ))}
            </div>
            );
          })}
          </div>
        </div>
      </div>

      <div class="flow-inspector">
        {!selected && <div class="hint">Select a node to edit it, or add one from the palette.</div>}
        {selected && (
          <Fragment>
            <div class="wizard-section-title">{KIND_LABEL[selected.kind]}</div>

            {selected.kind === 'trigger.button' && (
              <div class="wizard-field">
                <label>Action name</label>
                <input class="prop-input" value={selected.params.name ?? ''}
                  placeholder="e.g. open_valve"
                  onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
                <p class="hint">Fires when a button widget's Action is set to this name.</p>
              </div>
            )}

            {(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change') && (() => {
              return (
                <Fragment>
                  <div class="wizard-field">
                    <label>Signal</label>
                    <SignalPicker value={selected.params.signal ?? ''} options={allSignalOptions()}
                      onOpen={openAllSignals}
                      onChange={(ref) => patchParams(selected.id, { signal: ref })} />
                  </div>
                  {selected.kind === 'trigger.threshold' && (
                    <div class="wizard-field wizard-field-row">
                      <div class="wizard-field" style="flex:0 0 4.5rem;">
                        <label>Op</label>
                        <select class="prop-select" value={selected.params.op ?? '>'}
                          onChange={(e) => patchParams(selected.id, { op: (e.target as HTMLSelectElement).value })}>
                          {THRESHOLD_OPS.map(o => <option key={o} value={o}>{o}</option>)}
                        </select>
                      </div>
                      <div class="wizard-field" style="flex:1;">
                        <label>Value</label>
                        <input class="prop-input" value={selected.params.value ?? ''}
                          onInput={(e) => patchParams(selected.id, { value: (e.target as HTMLInputElement).value })} />
                      </div>
                    </div>
                  )}
                  {selected.kind === 'trigger.change' && <p class="hint">Fires whenever the signal's value changes.</p>}
                </Fragment>
              );
            })()}

            {(selected.kind === 'trigger.timer' || selected.kind === 'trigger.loop') && (
              <div class="wizard-field">
                <label>Interval (ms)</label>
                <input class="prop-input" type="number" value={selected.params.interval ?? '1000'}
                  onInput={(e) => patchParams(selected.id, { interval: (e.target as HTMLInputElement).value })} />
                {selected.kind === 'trigger.loop' && <p class="hint">Runs once on panel load, then on every interval.</p>}
              </div>
            )}

            {selected.kind === 'gate.and' && (
              <p class="hint">Wire two or more triggers into this gate. The flow continues only when all
                inputs are satisfied (e.g. a Button click while a Threshold is currently true).</p>
            )}

            {selected.kind === 'flow.if' && (
              <ExprField label="Condition" value={selected.params.cond ?? ''}
                onChange={(v) => patchParams(selected.id, { cond: v })}
                onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
                allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
                hint="True → 'then' port, false → 'else' port. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
            )}

            {selected.kind === 'flow.loop' && (
              <Fragment>
                <div class="wizard-field">
                  <label>Mode</label>
                  <select class="prop-select" value={selected.params.mode ?? 'count'}
                    onChange={(e) => patchParams(selected.id, { mode: (e.target as HTMLSelectElement).value })}>
                    <option value="count">Repeat N times</option>
                    <option value="while">While condition</option>
                  </select>
                </div>
                {(selected.params.mode ?? 'count') === 'count' ? (
                  <div class="wizard-field">
                    <label>Repeat count</label>
                    <input class="prop-input" type="number" value={selected.params.count ?? '3'}
                      onInput={(e) => patchParams(selected.id, { count: (e.target as HTMLInputElement).value })} />
                  </div>
                ) : (
                  <ExprField label="While condition" value={selected.params.cond ?? ''}
                    onChange={(v) => patchParams(selected.id, { cond: v })}
                    onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
                    allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
                    hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
                )}
                <p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
              </Fragment>
            )}

            {selected.kind === 'action.write' && (
              <Fragment>
                <div class="wizard-field">
                  <label>Target signal / variable</label>
                  <SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
                    onOpen={openAllSignals} allowFreeform
                    onChange={(ref) => patchParams(selected.id, { target: ref })} />
                  <p class="hint">Pick a signal, or type <code>local:name</code> to write a panel-local variable.</p>
                </div>
                <ExprField label="Value (expression)" value={selected.params.expr ?? ''}
                  onChange={(v) => patchParams(selected.id, { expr: v })}
                  onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
                  allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
                  hint="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
              </Fragment>
            )}

            {selected.kind === 'action.delay' && (
              <div class="wizard-field">
                <label>Delay (ms)</label>
                <input class="prop-input" type="number" value={selected.params.ms ?? '0'}
                  onInput={(e) => patchParams(selected.id, { ms: (e.target as HTMLInputElement).value })} />
              </div>
            )}

            {selected.kind === 'action.config.apply' && (
              <Fragment>
                {instanceField(selected)}
                <p class="hint">Applies the instance's current values to every bound signal (like the
                  Apply button in the config manager).</p>
              </Fragment>
            )}

            {selected.kind === 'action.config.read' && (
              <Fragment>
                {instanceField(selected)}
                <div class="wizard-field">
                  <label>Parameter key</label>
                  <input class="prop-input" value={selected.params.key ?? ''}
                    placeholder="parameter key"
                    onInput={(e) => patchParams(selected.id, { key: (e.target as HTMLInputElement).value })} />
                </div>
                <div class="wizard-field">
                  <label>Target signal / variable</label>
                  <SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
                    onOpen={openAllSignals} allowFreeform
                    onChange={(ref) => patchParams(selected.id, { target: ref })} />
                  <p class="hint">Reads the named parameter's value and writes it to this target
                    (numeric values only).</p>
                </div>
              </Fragment>
            )}

            {selected.kind === 'action.config.write' && (
              <Fragment>
                {instanceField(selected)}
                <div class="wizard-field">
                  <label>Parameter key</label>
                  <input class="prop-input" value={selected.params.key ?? ''}
                    placeholder="parameter key"
                    onInput={(e) => patchParams(selected.id, { key: (e.target as HTMLInputElement).value })} />
                </div>
                <ExprField label="Value (expression)" value={selected.params.expr ?? ''}
                  onChange={(v) => patchParams(selected.id, { expr: v })}
                  onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
                  allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
                  hint="Stores the value into the named parameter, creating a new instance revision (numeric values only)." />
              </Fragment>
            )}

            {selected.kind === 'action.config.create' && (
              <Fragment>
                <div class="wizard-field">
                  <label>Config set</label>
                  <select class="prop-select" value={selected.params.set ?? ''}
                    onChange={(e) => patchParams(selected.id, { set: (e.target as HTMLSelectElement).value })}>
                    <option value="">choose…</option>
                    {configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
                  </select>
                </div>
                <div class="wizard-field">
                  <label>New instance name</label>
                  <input class="prop-input" value={selected.params.name ?? ''}
                    placeholder="auto"
                    onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
                </div>
                <div class="wizard-field">
                  <label>Copy values from</label>
                  <select class="prop-select" value={selected.params.from ?? ''}
                    onChange={(e) => patchParams(selected.id, { from: (e.target as HTMLSelectElement).value })}>
                    <option value="">defaults (empty)</option>
                    {configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
                  </select>
                </div>
                <div class="wizard-field">
                  <label>Write new id to variable</label>
                  <SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
                    onOpen={openAllSignals} allowFreeform
                    onChange={(ref) => patchParams(selected.id, { target: ref })} />
                  <p class="hint">Creates a new instance for the chosen set (optionally seeded from another
                    instance) and writes its id to this panel variable, ready to feed apply/read/write
                    nodes set to "From variable".</p>
                </div>
              </Fragment>
            )}

            {selected.kind === 'action.config.snapshot' && (
              <Fragment>
                <div class="wizard-field">
                  <label>Config set</label>
                  <select class="prop-select" value={selected.params.set ?? ''}
                    onChange={(e) => patchParams(selected.id, { set: (e.target as HTMLSelectElement).value })}>
                    <option value="">choose…</option>
                    {configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
                  </select>
                </div>
                <div class="wizard-field">
                  <label>New instance name</label>
                  <input class="prop-input" value={selected.params.name ?? ''}
                    placeholder="(auto)"
                    onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
                </div>
                <div class="wizard-field">
                  <label>Write new id to variable</label>
                  <SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
                    onOpen={openAllSignals} allowFreeform
                    onChange={(ref) => patchParams(selected.id, { target: ref })} />
                  <p class="hint">Reads the current live value of every target signal of the set and stores
                    them as a new instance, then writes the new instance id to this panel variable.</p>
                </div>
              </Fragment>
            )}

            {selected.kind === 'action.accumulate' && (
              <Fragment>
                <div class="wizard-field">
                  <label>Array name</label>
                  <input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
                    placeholder="e.g. data"
                    onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
                </div>
                <ExprField label="Value (expression)" value={selected.params.expr ?? ''}
                  onChange={(v) => patchParams(selected.id, { expr: v })}
                  onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
                  allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
                  hint="Appends this value (with a timestamp) to the array. e.g. {stub:level}, or {sys:time} for the clock." />
              </Fragment>
            )}

            {selected.kind === 'action.export' && (
              <ExportEditor
                columns={parseExportColumns(selected)}
                align={selected.params.align ?? 'common'}
                filename={selected.params.filename ?? ''}
                onColumns={(cols) => patchParams(selected.id, { columns: JSON.stringify(cols) })}
                onAlign={(a) => patchParams(selected.id, { align: a })}
                onFilename={(f) => patchParams(selected.id, { filename: f })} />
            )}

            {selected.kind === 'action.clear' && (
              <div class="wizard-field">
                <label>Array name</label>
                <input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
                  placeholder="e.g. data"
                  onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
                <p class="hint">Empties the named array (e.g. to start a fresh capture).</p>
              </div>
            )}

            {selected.kind === 'action.log' && (
              <Fragment>
                <div class="wizard-field">
                  <label>Label (optional)</label>
                  <input class="prop-input" value={selected.params.label ?? ''}
                    placeholder="prefix for the console line"
                    onInput={(e) => patchParams(selected.id, { label: (e.target as HTMLInputElement).value })} />
                </div>
                <ExprField label="Value (expression)" value={selected.params.expr ?? ''}
                  onChange={(v) => patchParams(selected.id, { expr: v })}
                  onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
                  allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
                  hint="Logs this value to the browser console — handy for debugging a flow." />
              </Fragment>
            )}

            {selected.kind === 'action.widget' && (() => {
              const op = selected.params.op ?? 'disable';
              const plotOnly = op === 'clearplot' || op === 'pauseplot' || op === 'resumeplot';
              const choices = plotOnly ? widgets.filter(w => w.type === 'plot') : widgets;
              return (
                <Fragment>
                  <div class="wizard-field">
                    <label>Action</label>
                    <select class="prop-input" value={op}
                      onChange={(e) => patchParams(selected.id, { op: (e.target as HTMLSelectElement).value })}>
                      <option value="enable">Enable</option>
                      <option value="disable">Disable</option>
                      <option value="show">Show</option>
                      <option value="hide">Hide</option>
                      <option value="clearplot">Clear plot</option>
                      <option value="pauseplot">Pause plot</option>
                      <option value="resumeplot">Resume plot</option>
                    </select>
                  </div>
                  <div class="wizard-field">
                    <label>Widget</label>
                    <select class="prop-input" value={selected.params.widget ?? ''}
                      onChange={(e) => patchParams(selected.id, { widget: (e.target as HTMLSelectElement).value })}>
                      <option value="">— select a widget —</option>
                      {choices.map(w => (
                        <option value={w.id}>{widgetLabel(w)}</option>
                      ))}
                    </select>
                    {plotOnly && <p class="hint">Only plot widgets are listed for this action.</p>}
                  </div>
                </Fragment>
              );
            })()}

            {(selected.kind === 'trigger.start' || selected.kind === 'trigger.stop') && (
              <p class="hint">{selected.kind === 'trigger.start'
                ? 'Fires once when the panel is opened — use it to initialise state or greet the operator.'
                : 'Fires once when the panel is closed — use it to write a safe value or log a closing message. Only immediate (no-delay) actions are guaranteed to run.'}</p>
            )}

            {(selected.kind === 'action.dialog.info' || selected.kind === 'action.dialog.error') && (
              <Fragment>
                <div class="wizard-field">
                  <label>Title</label>
                  <input class="prop-input" value={selected.params.title ?? ''}
                    placeholder={selected.kind === 'action.dialog.error' ? 'Error' : 'Info'}
                    onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
                </div>
                <div class="wizard-field">
                  <label>Message</label>
                  <textarea class="prop-input" rows={4} value={selected.params.message ?? ''}
                    placeholder="Shown to the operator. Embed values as {ds:name}."
                    onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
                  <p class="hint">Waits for the operator to dismiss the dialog before continuing.
                    Embed live values with <code>{'{ds:name}'}</code> (e.g. Level is {'{stub:level}'}).</p>
                </div>
                <DialogFieldsEditor allowInput={false}
                  fields={parseDialogFields(selected)}
                  onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
                  allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
              </Fragment>
            )}

            {selected.kind === 'action.dialog.setpoint' && (
              <Fragment>
                <div class="wizard-field">
                  <label>Title</label>
                  <input class="prop-input" value={selected.params.title ?? ''}
                    placeholder="Set value"
                    onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
                </div>
                <div class="wizard-field">
                  <label>Message</label>
                  <textarea class="prop-input" rows={3} value={selected.params.message ?? ''}
                    placeholder="Prompt shown above the fields. Embed values as {ds:name}."
                    onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
                </div>
                <DialogFieldsEditor allowInput={true}
                  fields={parseDialogFields(selected)}
                  onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
                  allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
                <p class="hint">Each <b>input</b> prompts the operator and writes the entered number to its
                  target on OK; each <b>display</b> shows a live value. Cancel aborts the flow.</p>
              </Fragment>
            )}

            <button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
          </Fragment>
        )}
      </div>
    </div>
  );

  function paletteBtn(entry: PaletteEntry) {
    return (
      <button key={entry.kind} class={`flow-palette-btn flow-palette-${category(entry.kind)}`}
        title={`${entry.kind} — drag onto the canvas or click to add`}
        draggable
        onDragStart={(e) => {
          e.dataTransfer?.setData(DRAG_MIME, entry.kind);
          if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy';
        }}
        onClick={() => addNode(entry)}>{entry.label}</button>
    );
  }
}

// Expression input with a signal-insert helper and live validation.
function ExprField({ label, value, onChange, onInsert, allSignals, onOpenSignals, hint }: {
  label: string;
  value: string;
  onChange: (v: string) => void;
  onInsert: (ds: string, sig: string) => void;
  allSignals: SignalOption[];
  onOpenSignals: () => void;
  hint: string;
}) {
  const err = checkExpr(value);
  return (
    <div class="wizard-field">
      <label>{label}</label>
      <input class={`prop-input${err ? ' prop-input-error' : ''}`} value={value}
        placeholder="expression"
        onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
      {err && <p class="wizard-error">{err}</p>}
      <div class="flow-expr-insert">
        <SignalPicker value="" options={allSignals} onOpen={onOpenSignals}
          placeholder="+ insert signal"
          onChange={(ref) => { const s = splitRef(ref); onInsert(s.ds, s.name); }} />
      </div>
      <p class="hint">{hint}</p>
    </div>
  );
}

// ── Export CSV: multi-column editor ──────────────────────────────────────────

interface ExportColumn { array: string; label: string; }

// The columns configured on an action.export node. Reads the JSON `columns`
// param; falls back to the legacy single `array` param (so old panels migrate
// transparently the first time the node is edited).
function parseExportColumns(n: LogicNode): ExportColumn[] {
  const raw = (n.params.columns ?? '').trim();
  if (raw) {
    try {
      const parsed = JSON.parse(raw);
      if (Array.isArray(parsed)) {
        return parsed.map((c: any) => ({ array: String(c?.array ?? ''), label: String(c?.label ?? '') }));
      }
    } catch {}
  }
  const a = (n.params.array ?? '').trim();
  return [{ array: a, label: '' }];
}

const ALIGN_OPTS: Array<{ value: string; label: string; hint: string }> = [
  { value: 'common', label: 'Common rows', hint: 'Only timestamps present in every array (intersection).' },
  { value: 'any', label: 'All rows', hint: 'Union of all timestamps; missing cells are left blank.' },
  { value: 'interpolate', label: 'Interpolate', hint: 'Union of all timestamps; missing cells linearly interpolated.' },
];

function ExportEditor({ columns, align, filename, onColumns, onAlign, onFilename }: {
  columns: ExportColumn[];
  align: string;
  filename: string;
  onColumns: (cols: ExportColumn[]) => void;
  onAlign: (a: string) => void;
  onFilename: (f: string) => void;
}) {
  function patch(i: number, patch: Partial<ExportColumn>) {
    onColumns(columns.map((c, j) => (j === i ? { ...c, ...patch } : c)));
  }
  const alignHint = ALIGN_OPTS.find(o => o.value === align)?.hint ?? '';
  return (
    <Fragment>
      <div class="wizard-field">
        <label>Columns (arrays)</label>
        {columns.map((c, i) => (
          <div key={i} class="flow-row-edit">
            <input class="prop-input" value={c.array} list="flow-array-names"
              placeholder="array name"
              onInput={(e) => patch(i, { array: (e.target as HTMLInputElement).value })} />
            <input class="prop-input" value={c.label}
              placeholder="column label (optional)"
              onInput={(e) => patch(i, { label: (e.target as HTMLInputElement).value })} />
            <button class="flow-node-del" title="Remove column"
              disabled={columns.length <= 1}
              onClick={() => onColumns(columns.filter((_, j) => j !== i))}>✕</button>
          </div>
        ))}
        <button class="panel-btn flow-row-add" onClick={() => onColumns([...columns, { array: '', label: '' }])}>+ Add column</button>
      </div>
      {columns.length > 1 && (
        <div class="wizard-field">
          <label>Alignment</label>
          <select class="prop-select" value={align} onChange={(e) => onAlign((e.target as HTMLSelectElement).value)}>
            {ALIGN_OPTS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
          </select>
          <p class="hint">{alignHint}</p>
        </div>
      )}
      <div class="wizard-field">
        <label>File name</label>
        <input class="prop-input" value={filename}
          placeholder="defaults to the column labels"
          onInput={(e) => onFilename((e.target as HTMLInputElement).value)} />
        <p class="hint">Downloads a CSV with columns: timestamp_ms, iso, then one column per array.</p>
      </div>
    </Fragment>
  );
}

// ── Dialogs: multi-field editor ──────────────────────────────────────────────

interface DialogFieldSpec { type: 'input' | 'display'; label: string; target?: string; default?: string; expr?: string; }

// The fields configured on an action.dialog.* node. Reads the JSON `fields`
// param; for a legacy set-point node it synthesises a single input from the old
// target/default params so existing panels migrate transparently.
function parseDialogFields(n: LogicNode): DialogFieldSpec[] {
  const raw = (n.params.fields ?? '').trim();
  if (raw) {
    try {
      const parsed = JSON.parse(raw);
      if (Array.isArray(parsed)) {
        return parsed.map((f: any) => ({
          type: f?.type === 'display' ? 'display' : 'input',
          label: String(f?.label ?? ''),
          target: String(f?.target ?? ''),
          default: String(f?.default ?? ''),
          expr: String(f?.expr ?? ''),
        }));
      }
    } catch {}
  }
  if (n.kind === 'action.dialog.setpoint') {
    return [{ type: 'input', label: '', target: n.params.target ?? '', default: n.params.default ?? '', expr: '' }];
  }
  return [];
}

function DialogFieldsEditor({ allowInput, fields, onChange, allSignals, onOpenSignals }: {
  allowInput: boolean;
  fields: DialogFieldSpec[];
  onChange: (fields: DialogFieldSpec[]) => void;
  allSignals: SignalOption[];
  onOpenSignals: () => void;
}) {
  function patch(i: number, patch: Partial<DialogFieldSpec>) {
    onChange(fields.map((f, j) => (j === i ? { ...f, ...patch } : f)));
  }
  function add(type: 'input' | 'display') {
    onChange([...fields, { type, label: '', target: '', default: '', expr: '' }]);
  }
  return (
    <div class="wizard-field">
      <label>{allowInput ? 'Fields' : 'Displayed values'}</label>
      {fields.length === 0 && <p class="hint">None yet.</p>}
      {fields.map((f, i) => {
        return (
          <div key={i} class="flow-field-edit">
            <div class="flow-field-head">
              {allowInput ? (
                <select class="prop-select" value={f.type}
                  onChange={(e) => patch(i, { type: (e.target as HTMLSelectElement).value as 'input' | 'display' })}>
                  <option value="input">Input</option>
                  <option value="display">Display</option>
                </select>
              ) : <span class="flow-field-kind">Display</span>}
              <input class="prop-input" value={f.label} placeholder="label"
                onInput={(e) => patch(i, { label: (e.target as HTMLInputElement).value })} />
              <button class="flow-node-del" title="Remove field"
                onClick={() => onChange(fields.filter((_, j) => j !== i))}>✕</button>
            </div>
            {f.type === 'input' ? (
              <Fragment>
                <div class="flow-row-edit">
                  <SignalPicker value={f.target ?? ''} options={allSignals}
                    onOpen={onOpenSignals} allowFreeform placeholder="target signal…"
                    onChange={(ref) => patch(i, { target: ref })} />
                </div>
                <input class={`prop-input${checkExpr(f.default ?? '') ? ' prop-input-error' : ''}`}
                  value={f.default ?? ''} placeholder="default value (expression, optional)"
                  onInput={(e) => patch(i, { default: (e.target as HTMLInputElement).value })} />
              </Fragment>
            ) : (
              <input class={`prop-input${checkExpr(f.expr ?? '') ? ' prop-input-error' : ''}`}
                value={f.expr ?? ''} placeholder="value to show (expression, e.g. {stub:level})"
                onInput={(e) => patch(i, { expr: (e.target as HTMLInputElement).value })} />
            )}
          </div>
        );
      })}
      <div class="flow-field-add">
        {allowInput && <button class="panel-btn flow-row-add" onClick={() => add('input')}>+ Add input</button>}
        <button class="panel-btn flow-row-add" onClick={() => add('display')}>+ Add value to show</button>
      </div>
    </div>
  );
}

// A human-friendly label for a widget in the Widget-control picker: its type
// plus its first signal (or id) so panels with many widgets stay legible.
function widgetLabel(w: Widget): string {
  const sig = w.signals[0] ? `${w.signals[0].ds}:${w.signals[0].name}` : '';
  return `${w.type}${sig ? ' · ' + sig : ''} (${w.id})`;
}

// One-line summary shown in a node block's body.
function nodeSummary(n: LogicNode): string {
  switch (n.kind) {
    case 'trigger.button':    return n.params.name || '(unnamed)';
    case 'trigger.threshold': return `${n.params.signal || '?'} ${n.params.op || '>'} ${n.params.value || '0'}`;
    case 'trigger.change':    return `Δ ${n.params.signal || '?'}`;
    case 'trigger.timer':     return `every ${n.params.interval || '?'} ms`;
    case 'trigger.loop':      return `load + every ${n.params.interval || '?'} ms`;
    case 'trigger.start':     return 'on panel open';
    case 'trigger.stop':      return 'on panel close';
    case 'gate.and':          return 'all inputs';
    case 'flow.if':           return n.params.cond || '(no condition)';
    case 'flow.loop':         return (n.params.mode ?? 'count') === 'count'
                                ? `repeat ${n.params.count || '0'}×`
                                : `while ${n.params.cond || '?'}`;
    case 'action.write':      return `${n.params.target || '?'} = ${n.params.expr || ''}`;
    case 'action.delay':      return `wait ${n.params.ms || '0'} ms`;
    case 'action.accumulate': return `${n.params.array || '?'} ← ${n.params.expr || ''}`;
    case 'action.export': {
      const cols = parseExportColumns(n).filter(c => c.array);
      const names = cols.map(c => c.label || c.array).join(', ');
      return `export ${names || '?'} → csv`;
    }
    case 'action.clear':      return `clear ${n.params.array || '?'}`;
    case 'action.log':        return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
    case 'action.widget':     return `${n.params.op || 'disable'} ${n.params.widget || '?'}`;
    case 'action.dialog.info':     return `info: ${n.params.title || n.params.message || '…'}`;
    case 'action.dialog.error':    return `error: ${n.params.title || n.params.message || '…'}`;
    case 'action.dialog.setpoint': {
      const fs = parseDialogFields(n);
      const ins = fs.filter(f => f.type === 'input').length;
      const shows = fs.filter(f => f.type === 'display').length;
      const parts: string[] = [];
      if (ins) parts.push(`ask ${ins}`);
      if (shows) parts.push(`show ${shows}`);
      return parts.length ? parts.join(', ') : 'ask…';
    }
    default:                  return '';
  }
}

// Inline editor for the panel's local state variables, shown in the logic
// palette so they can be created without leaving the (full-width) logic tab.
function LocalVars({ statevars, onChange }: {
  statevars: StateVar[];
  onChange: (vars: StateVar[]) => void;
}) {
  const [open, setOpen] = useState(false);
  const [name, setName] = useState('');
  const [type, setType] = useState<'number' | 'bool' | 'string'>('number');
  const [initial, setInitial] = useState('0');

  function add() {
    const n = name.trim();
    if (!n) return;
    onChange([...statevars.filter(v => v.name !== n), { name: n, type, initial }]);
    setName('');
    setInitial('0');
  }

  return (
    <div class="flow-localvars">
      <div class="flow-palette-title">Local vars</div>
      {statevars.length === 0 && <div class="hint flow-localvars-empty">None yet.</div>}
      {statevars.map(v => (
        <div key={v.name} class="flow-localvar-row">
          <span class="flow-localvar-name" title={`${v.type ?? 'number'} = ${v.initial}`}>{v.name}</span>
          <button class="flow-node-del" title="Remove"
            onClick={() => onChange(statevars.filter(x => x.name !== v.name))}>✕</button>
        </div>
      ))}
      {!open && (
        <button class="flow-palette-btn flow-localvar-add" onClick={() => setOpen(true)}>+ Add variable</button>
      )}
      {open && (
        <div class="flow-localvar-form">
          <input class="prop-input" value={name} placeholder="name"
            onInput={(e) => setName((e.target as HTMLInputElement).value)} />
          <select class="prop-select" value={type}
            onChange={(e) => setType((e.target as HTMLSelectElement).value as 'number' | 'bool' | 'string')}>
            <option value="number">number</option>
            <option value="bool">bool</option>
            <option value="string">string</option>
          </select>
          <input class="prop-input" value={initial} placeholder="initial"
            onInput={(e) => setInitial((e.target as HTMLInputElement).value)} />
          <div class="flow-localvar-actions">
            <button class="panel-btn" onClick={add}>Add</button>
            <button class="panel-btn" onClick={() => { setOpen(false); setName(''); }}>Cancel</button>
          </div>
        </div>
      )}
    </div>
  );
}
