import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SignalPicker, { SignalOption } from './SignalPicker';
import LuaEditor from './LuaEditor';
import { checkExpr } from './lib/expr';
import { VersionTree, DiffViewer } from './VersionHistory';
import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup, type Rect } from './lib/nodeGroups';
import { useFlowZoom, type Box } from './lib/flowZoom';
import { formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug';
import { wsClient } from './lib/ws';
import { useAuth } from './lib/auth';
import { ScopeFilter, ScopePicker, filterByScope, normalizeScope, type Bucket } from './lib/scope';

// ── Types (mirror internal/controllogic/model.go) ────────────────────────────

type CLNodeKind =
  | 'trigger.threshold'
  | 'trigger.change'
  | 'trigger.timer'
  | 'trigger.cron'
  | 'trigger.alarm'
  | 'gate.and'
  | 'flow.if'
  | 'flow.loop'
  | 'action.write'
  | 'action.delay'
  | 'action.log'
  | 'action.lua'
  | 'action.dialog'
  | 'action.config.apply'
  | 'action.config.read'
  | 'action.config.write'
  | 'action.config.create'
  | 'action.config.snapshot';

interface CLNode {
  id: string;
  kind: CLNodeKind;
  x: number;
  y: number;
  params: Record<string, string>;
}
interface CLWire { from: string; fromPort?: string; to: string; }
interface CLGraph {
  id: string;
  name: string;
  enabled: boolean;
  nodes: CLNode[];
  wires: CLWire[];
  groups?: NodeGroup[];
  owner?: string;
  scope?: string;
  scopeGroups?: string[];
}

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

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) };
}

// ── Geometry (shared with LogicEditor look/feel) ─────────────────────────────

const REM = (() => {
  if (typeof document === 'undefined') return 16;
  return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
})();
const NODE_W = 11.5 * REM;
const CANVAS_W = 125 * REM; // logical canvas size (matches .flow-canvas-inner)
const CANVAS_H = 87.5 * REM;
const PORT_TOP = 2 * REM;
const PORT_GAP = 1.375 * REM;
const PORT_R = 0.375 * REM;
// 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;
const GROUP_HEADER = 1.65 * REM;
const GROUP_BOX_W = NODE_W;
const GROUP_BOX_H = GROUP_HEADER + 1.1 * REM;

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

const PALETTE: PaletteEntry[] = [
  { 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.cron',      label: 'Cron',      params: { spec: '*/5 * * * *' } },
  { kind: 'trigger.alarm',     label: 'Alarm',     params: { signal: '', min: '0', max: '100' } },
  { 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.log',        label: 'Log',       params: { expr: '', label: '' } },
  { kind: 'action.lua',        label: 'Lua script', params: { script: '-- get("ds:name") reads, set("ds:name", v) writes,\n-- log("msg") logs to the server.\n' } },
  { kind: 'action.dialog',     label: 'Dialog',    params: { kind: 'info', title: '', message: '', users: '', groups: '', target: '' } },
  { kind: 'action.config.apply', label: 'Apply config', params: { instance: '' } },
  { kind: 'action.config.read',  label: 'Read config',  params: { instance: '', key: '', target: '' } },
  { kind: 'action.config.write', label: 'Write config', params: { instance: '', key: '', expr: '' } },
  { kind: 'action.config.create', label: 'Create config', params: { set: '', name: 'auto', from: '' } },
  { kind: 'action.config.snapshot', label: 'Snapshot config', params: { set: '', name: '' } },
];

const KIND_LABEL: Record<CLNodeKind, string> = {
  'trigger.threshold': 'Threshold',
  'trigger.change': 'On change',
  'trigger.timer': 'Timer',
  'trigger.cron': 'Cron',
  'trigger.alarm': 'Alarm',
  'gate.and': 'AND gate',
  'flow.if': 'If / else',
  'flow.loop': 'Loop',
  'action.write': 'Write',
  'action.delay': 'Delay',
  'action.log': 'Log',
  'action.lua': 'Lua script',
  'action.dialog': '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;

const CRON_PRESETS: { label: string; spec: string }[] = [
  { label: 'Every minute', spec: '* * * * *' },
  { label: 'Every 5 minutes', spec: '*/5 * * * *' },
  { label: 'Every hour', spec: '0 * * * *' },
  { label: 'Daily at 08:00', spec: '0 8 * * *' },
  { label: 'Weekdays at 09:00', spec: '0 9 * * 1-5' },
];

function isTrigger(kind: CLNodeKind): boolean { return kind.startsWith('trigger.'); }
function category(kind: CLNodeKind): '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: CLNodeKind): boolean { return !isTrigger(kind); }

interface Port { id: string; label: string; }
function outputs(kind: CLNodeKind): 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: CLNodeKind): number { return PORT_TOP + outputs(kind).length * PORT_GAP; }

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

function inAnchor(n: CLNode) { return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
function outAnchor(n: CLNode, 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}`;
}

// ── Manager modal ────────────────────────────────────────────────────────────

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

export default function ControlLogicEditor({ onClose }: Props) {
  const me = useAuth();
  const [graphs, setGraphs] = useState<CLGraph[]>([]);
  const [bucket, setBucket] = useState<Bucket>('mine');
  const [scopeGroup, setScopeGroup] = useState('');
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [graph, setGraph] = useState<CLGraph | null>(null);
  const [dirty, setDirty] = useState(false);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [showCloseConfirm, setShowCloseConfirm] = useState(false);
  const [showVersions, setShowVersions] = useState(false);
  const [viewingVersion, setViewingVersion] = useState<number | null>(null);
  const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null);
  const [verReload, setVerReload] = useState(0);

  // Closing with unsaved changes prompts instead of silently discarding them.
  function requestClose() {
    if (dirty) { setShowCloseConfirm(true); return; }
    onClose();
  }
  async function saveAndClose() {
    if (await saveGraph()) { setShowCloseConfirm(false); onClose(); }
  }

  async function reload() {
    try {
      const res = await fetch('/api/v1/controllogic');
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      setGraphs(await res.json());
    } catch (err) {
      setError(`Failed to load control logic: ${err instanceof Error ? err.message : err}`);
    }
  }
  useEffect(() => { reload(); }, []);

  function selectGraph(g: CLGraph) {
    if (dirty && !confirm('Discard unsaved changes to the current graph?')) return;
    setSelectedId(g.id);
    setGraph(JSON.parse(JSON.stringify(g)));
    setDirty(false);
    setError(null);
  }

  async function createGraph() {
    setBusy(true);
    setError(null);
    try {
      const body: Omit<CLGraph, 'id'> = { name: 'New control logic', enabled: false, nodes: [], wires: [] };
      const res = await fetch('/api/v1/controllogic', {
        method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const created: CLGraph = await res.json();
      await reload();
      setSelectedId(created.id);
      setGraph(created);
      setDirty(false);
    } catch (err) {
      setError(`Create failed: ${err instanceof Error ? err.message : err}`);
    } finally {
      setBusy(false);
    }
  }

  async function saveGraph(): Promise<boolean> {
    if (!graph) return false;
    setBusy(true);
    setError(null);
    try {
      const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(graph.id)}`, {
        method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(graph),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      setDirty(false);
      await reload();
      return true;
    } catch (err) {
      setError(`Save failed: ${err instanceof Error ? err.message : err}`);
      return false;
    } finally {
      setBusy(false);
    }
  }

  async function deleteGraph(id: string) {
    if (!confirm('Delete this control logic graph? This stops it on the server.')) return;
    setBusy(true);
    setError(null);
    try {
      const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(id)}`, { method: 'DELETE' });
      if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`);
      if (selectedId === id) { setSelectedId(null); setGraph(null); setDirty(false); }
      await reload();
    } catch (err) {
      setError(`Delete failed: ${err instanceof Error ? err.message : err}`);
    } finally {
      setBusy(false);
    }
  }

  // Toggle a graph's enabled flag straight from the list (persists immediately).
  async function toggleEnabled(g: CLGraph) {
    setBusy(true);
    setError(null);
    try {
      const updated = { ...g, enabled: !g.enabled };
      const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(g.id)}`, {
        method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      if (selectedId === g.id && graph) setGraph({ ...graph, enabled: updated.enabled });
      await reload();
    } catch (err) {
      setError(`Update failed: ${err instanceof Error ? err.message : err}`);
    } finally {
      setBusy(false);
    }
  }

  function patchGraph(patch: Partial<CLGraph>) {
    if (!graph) return;
    setGraph({ ...graph, ...patch });
    setDirty(true);
  }

  // Load a past revision's content into the editor as unsaved edits; saving it
  // promotes it to a new current version (non-destructive).
  async function viewVersion(version: number) {
    if (!graph) return;
    if (dirty && !confirm('Discard unsaved changes to load this version?')) return;
    try {
      const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(graph.id)}/versions/${version}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const g: CLGraph = await res.json();
      setGraph(g);
      setViewingVersion(version);
      setDirty(false);
    } catch (err) {
      setError(`Load version failed: ${err instanceof Error ? err.message : err}`);
    }
  }

  const visibleGraphs = filterByScope(graphs, me, bucket, scopeGroup, g => g.scopeGroups ?? []);

  return (
    <div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) requestClose(); }}>
      <div class="cl-modal cl-modal-full">
        <header class="cl-header">
          <span class="cl-title">Control logic</span>
          <span class="hint cl-subtitle">Server-side flows — run continuously, independent of any panel.</span>
          <div class="cl-header-actions">
            {graph && dirty && <span class="cl-dirty">unsaved</span>}
            {graph && <button class="panel-btn" disabled={busy || !dirty} onClick={saveGraph}>Save</button>}
            {graph && <button class={`panel-btn${showVersions ? ' panel-btn-primary' : ''}`}
              onClick={() => setShowVersions(v => !v)}>History</button>}
            <button class="panel-btn" onClick={requestClose}>Close</button>
          </div>
        </header>

        {error && <div class="cl-error">{error}</div>}

        <div class="cl-body">
          <div class="cl-list">
            <div class="cl-list-head">
              <span>Graphs</span>
              <button class="panel-btn" disabled={busy} onClick={createGraph}>+ New</button>
            </div>
            <ScopeFilter me={me} value={bucket} onChange={setBucket} group={scopeGroup} onGroupChange={setScopeGroup} />
            {graphs.length === 0 && <div class="hint cl-list-empty">No control logic yet.</div>}
            {graphs.length > 0 && visibleGraphs.length === 0 && <div class="hint cl-list-empty">No graphs in this view.</div>}
            {visibleGraphs.map(g => (
              <div key={g.id}
                class={`cl-list-item${selectedId === g.id ? ' cl-list-item-active' : ''}`}
                onClick={() => selectGraph(g)}>
                <span class={`cl-status-dot${g.enabled ? ' cl-status-on' : ''}`}
                  title={g.enabled ? 'Enabled' : 'Disabled'} />
                <span class="cl-list-name" title={g.name}>{g.name || '(unnamed)'}</span>
                <button class="cl-mini-btn" title={g.enabled ? 'Disable' : 'Enable'}
                  onClick={(e) => { e.stopPropagation(); toggleEnabled(g); }}>
                  {g.enabled ? '⏸' : '▶'}
                </button>
                <button class="cl-mini-btn" title="Delete"
                  onClick={(e) => { e.stopPropagation(); deleteGraph(g.id); }}>✕</button>
              </div>
            ))}
          </div>

          <div class="cl-editor-wrap">
            {!graph && <div class="cl-empty hint">Select a graph on the left, or create a new one.</div>}
            {graph && (
              <Fragment>
                <div class="cl-graph-bar">
                  <input class="prop-input cl-name-input" value={graph.name}
                    placeholder="Graph name"
                    onInput={(e) => patchGraph({ name: (e.target as HTMLInputElement).value })} />
                  <label class="cl-enable">
                    <input type="checkbox" checked={graph.enabled}
                      onChange={(e) => patchGraph({ enabled: (e.target as HTMLInputElement).checked })} />
                    Enabled
                  </label>
                  <ScopePicker me={me} scope={normalizeScope(graph.scope)} groups={graph.scopeGroups ?? []}
                    onChange={(scope, scopeGroups) => patchGraph({ scope, scopeGroups })} />
                  <span class="hint">Saving a disabled graph stops it; enabling + saving starts it.</span>
                </div>
                <div class="cl-editor-main">
                  <FlowEditor graph={graph}
                    onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires, groups: g.groups }); setDirty(true); }} />
                  {showVersions && (
                    <div class="ver-pane">
                      <div class="ver-pane-head">Version history</div>
                      <VersionTree
                        base={`/api/v1/controllogic/${encodeURIComponent(graph.id)}`}
                        viewing={viewingVersion}
                        dirty={dirty}
                        reloadKey={verReload}
                        onView={viewVersion}
                        onForked={async (newId) => { await reload(); setSelectedId(newId); const r = await fetch(`/api/v1/controllogic/${encodeURIComponent(newId)}`); if (r.ok) { setGraph(await r.json()); setViewingVersion(null); setDirty(false); } }}
                        onPromoted={async () => { await reload(); const r = await fetch(`/api/v1/controllogic/${encodeURIComponent(graph.id)}`); if (r.ok) { setGraph(await r.json()); setViewingVersion(null); setDirty(false); } setVerReload(k => k + 1); }}
                        onCompare={(av, bv) => setDiff({ av, bv })}
                        onError={setError} />
                    </div>
                  )}
                </div>
              </Fragment>
            )}
          </div>
        </div>

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

        {showCloseConfirm && (
          <div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) setShowCloseConfirm(false); }}>
            <div class="cl-confirm">
              <div class="cl-confirm-title">Unsaved changes</div>
              <p class="hint">This graph has unsaved changes. What would you like to do?</p>
              <div class="cl-confirm-actions">
                <button class="panel-btn" onClick={() => setShowCloseConfirm(false)}>Cancel</button>
                <button class="panel-btn" disabled={busy}
                  onClick={() => { setShowCloseConfirm(false); onClose(); }}>Close without saving</button>
                <button class="panel-btn panel-btn-primary" disabled={busy} onClick={saveAndClose}>Save and close</button>
              </div>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

// ── Node-graph editor ─────────────────────────────────────────────────────────

function FlowEditor({ graph, onChange }: {
  graph: CLGraph;
  onChange: (g: { nodes: CLNode[]; wires: CLWire[]; groups?: NodeGroup[] }) => void;
}) {
  const nodes = graph.nodes;
  const wires = graph.wires;
  const groups = graph.groups ?? [];

  const [selectedNode, setSelectedNode] = useState<string | null>(null);
  const [selectedWire, setSelectedWire] = useState<number | null>(null);
  const [selSet, setSelSet] = useState<Set<string>>(new Set());
  const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
  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 this graph. `graphRef` always holds
  // the latest {nodes,wires,groups} so the ref-based stacks read a stable value
  // across renders. Snapshots are pushed right before each discrete edit.
  type Snapshot = { nodes: CLNode[]; wires: CLWire[]; groups: NodeGroup[] };
  const graphRef = useRef<Snapshot>({ nodes, wires, groups });
  graphRef.current = { nodes, wires, groups };
  const undoStack = useRef<Snapshot[]>([]);
  const redoStack = useRef<Snapshot[]>([]);
  const clipboard = useRef<{ nodes: CLNode[]; wires: CLWire[] } | 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: observe the running graph ('live') or dry-run the unsaved edits
  // ('simulate'). Node events arrive over the WS; debugStates holds the latest
  // value/timestamp per node and debugSnap is the decayed view the nodes render.
  const [debug, setDebug] = useState(false);
  const [debugMode, setDebugMode] = useState<'live' | 'simulate'>('live');
  const [debugSnap, setDebugSnap] = useState<DebugSnapshot>(new Map());
  const debugStates = useRef<Map<string, { value: number; hasValue: boolean; ts: number }>>(new Map());

  // Debug session: register the node-event listener, (live) subscribe to the
  // running graph, and rebuild the rendered snapshot on a 250 ms tick so node
  // highlights fade ~0.8 s after a node last fired. The simulate subscription is
  // handled by the separate effect below so it can re-send on each edit.
  useEffect(() => {
    if (!debug || !graph) return;
    debugStates.current = new Map();
    setDebugSnap(new Map());
    const off = wsClient.onDebugNode((ev) => {
      debugStates.current.set(ev.nodeId, { value: ev.value, hasValue: ev.hasValue, ts: Date.now() });
    });
    if (debugMode === 'live') wsClient.debugSubscribeLive(graph.id);
    const iv = setInterval(() => {
      const now = Date.now();
      const snap: DebugSnapshot = new Map();
      for (const [id, s] of debugStates.current) {
        snap.set(id, { value: s.hasValue ? s.value : undefined, type: 'scalar', active: now - s.ts < 800 });
      }
      setDebugSnap(snap);
    }, 250);
    return () => { off(); wsClient.debugUnsubscribe(); clearInterval(iv); };
  }, [debug, debugMode, graph?.id]);

  // Simulate mode: (re)compile the unsaved graph server-side on toggle and after
  // each edit (debounced) so the sandbox always reflects what's on screen.
  useEffect(() => {
    if (!debug || debugMode !== 'simulate' || !graph) return;
    const t = setTimeout(() => {
      wsClient.debugSubscribeSimulate({ id: graph.id, name: graph.name, nodes, wires, groups });
    }, 400);
    return () => clearTimeout(t);
  }, [debug, debugMode, nodes, wires, groups, graph?.id]);

  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 {}
  }

  // Graph-local write targets (bare name, or an explicit "local:" prefix) become
  // the suggestions offered when the target data source is 'local'.
  const localNames = Array.from(new Set(
    nodes.filter(n => n.kind === 'action.write')
      .map(n => n.params.target ?? '')
      .map(t => t.startsWith('local:') ? t.slice('local:'.length) : t)
      .filter(t => t && !t.includes(':'))
  ));
  // 'srv' is a registered data source, so it already appears in dataSources.
  const dsOptions = ['local', 'sys', ...dataSources];
  function signalOptions(ds: string): string[] {
    if (ds === 'local') return localNames;
    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)); }

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

  useEffect(() => {
    if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change' || selected?.kind === 'trigger.alarm') {
      loadSignals(splitRef(selected.params.signal ?? '').ds);
    }
    if (selected?.kind === 'action.write') loadSignals(splitRef(selected.params.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); setSelectedGroup(null); setSelSet(new Set());
    onChange(next);
    bump();
  }

  // Apply a graph change, recording an undo entry unless `record` is false (used
  // for the intermediate frames of a node drag, which record once on first move).
  function emit(next: { nodes?: CLNode[]; wires?: CLWire[]; groups?: NodeGroup[] }, record = true) {
    if (record) pushUndo();
    onChange({
      nodes: next.nodes ?? nodes,
      wires: next.wires ?? wires,
      groups: next.groups ?? groups,
    });
  }
  function setNodes(next: CLNode[], record = true) { emit({ nodes: next }, record); }
  function setWires(next: CLWire[]) { emit({ wires: next }); }
  function setGroups(next: NodeGroup[]) { emit({ groups: next }); }

  function addNode(entry: PaletteEntry, x?: number, y?: number) {
    const node: CLNode = {
      id: genId(),
      kind: entry.kind,
      x: x ?? 40 + (nodes.length % 5) * 30,
      y: y ?? 40 + (nodes.length % 5) * 30,
      params: { ...entry.params },
    };
    emit({ nodes: [...nodes, node] });
    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)));
  }
  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);
  }
  function deleteNode(id: string) {
    const nextNodes = nodes.filter(n => n.id !== id);
    emit({
      nodes: nextNodes,
      wires: wires.filter(w => w.from !== id && w.to !== id),
      groups: pruneGroups(groups, new Set(nextNodes.map(n => n.id))),
    });
    if (selectedNode === id) setSelectedNode(null);
    if (selSet.has(id)) { const s = new Set(selSet); s.delete(id); setSelSet(s); }
  }

  // ── 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)));
  }
  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 current selection (multi-select aware) plus any wires fully
  // internal to it, with params deep-cloned.
  function copySelection() {
    const cur = graphRef.current;
    const ids = selSet.size > 0 ? selSet : (selectedNode ? new Set([selectedNode]) : new Set<string>());
    if (ids.size === 0) return;
    const picked = cur.nodes.filter(n => ids.has(n.id));
    if (picked.length === 0) return;
    const innerWires = cur.wires.filter(w => ids.has(w.from) && ids.has(w.to));
    clipboard.current = {
      nodes: picked.map(n => ({ ...n, params: { ...n.params } })),
      wires: innerWires.map(w => ({ ...w })),
    };
  }
  function pasteClipboard() {
    const clip = clipboard.current;
    if (!clip || clip.nodes.length === 0) return;
    const idMap = new Map<string, string>();
    const cur = graphRef.current;
    const newNodes = clip.nodes.map(n => {
      const id = genId();
      idMap.set(n.id, id);
      return { ...n, id, x: n.x + 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)! }));
    emit({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires] });
    setSelSet(new Set(newNodes.map(n => n.id)));
    setSelectedNode(newNodes.length === 1 ? newNodes[0].id : null);
    setSelectedWire(null);
    setSelectedGroup(null);
  }

  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 };
  }

  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: CLNode) {
    e.stopPropagation();
    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);
    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);
  }

  function startWire(e: MouseEvent, node: CLNode, 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 });
  }
  function endWire() {
    pendingRef.current = null;
    window.removeEventListener('mousemove', onWireMove);
    window.removeEventListener('mouseup', onWireUp);
    setPendingWire(null);
  }
  function onWireUp() { endWire(); }
  function finishWire(target: CLNode) {
    const cur = pendingRef.current;
    if (cur && hasInput(target.kind)) addWire(cur.from, cur.port, target.id);
    endWire();
  }

  const DRAG_MIME = 'application/x-uopi-cl-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);
    addNode(entry, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - PORT_TOP / 2));
  }

  useEffect(() => {
    function onKey(e: KeyboardEvent) {
      const t = e.target as Element;
      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
      const mod = e.ctrlKey || e.metaKey;
      if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
      if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
      if (mod && e.key.toLowerCase() === 'c') { e.preventDefault(); copySelection(); return; }
      if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); pasteClipboard(); return; }
      if (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]));

  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[] => 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);

  const resolveOut = (n: CLNode, 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: CLNode) => {
    const box = collapsedBoxOf(n.id);
    return box ? { x: box.x, y: box.y + box.h / 2 } : inAnchor(n);
  };
  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={debugMode === 'live'
              ? 'Live debug — observe the running graph\u2019s node values'
              : 'Live debug — dry-run the unsaved edits (no real writes)'}>◉</button>
          {debug && (
            <button class="toolbar-btn flow-debug-mode" onClick={() => setDebugMode(m => m === 'live' ? 'simulate' : 'live')}
              title="Switch between observing the running graph (Live) and dry-running the unsaved edits (Simulate)">
              {debugMode === 'live' ? 'Live' : 'Sim'}
            </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 graph-local vars by name.
        </div>
      </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}>
          {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) wsClient.debugFireTrigger(node.id); }}
              onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
              {dbg && <span class="flow-node-badge" title={badgeTitle(dbg)}>{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.threshold' || selected.kind === 'trigger.change' || selected.kind === 'trigger.alarm') && (() => {
              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.alarm' && (
                    <div class="wizard-field wizard-field-row">
                      <div class="wizard-field" style="flex:1;">
                        <label>Min</label>
                        <input class="prop-input" type="number" value={selected.params.min ?? '0'}
                          onInput={(e) => patchParams(selected.id, { min: (e.target as HTMLInputElement).value })} />
                      </div>
                      <div class="wizard-field" style="flex:1;">
                        <label>Max</label>
                        <input class="prop-input" type="number" value={selected.params.max ?? '100'}
                          onInput={(e) => patchParams(selected.id, { max: (e.target as HTMLInputElement).value })} />
                      </div>
                    </div>
                  )}
                  {selected.kind === 'trigger.change' && <p class="hint">Fires whenever the signal's value changes.</p>}
                  {selected.kind === 'trigger.alarm' && <p class="hint">Fires when the signal leaves the [min, max] range (rising edge).</p>}
                </Fragment>
              );
            })()}

            {selected.kind === 'trigger.timer' && (
              <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 })} />
                <p class="hint">Fires repeatedly on the server while the graph is enabled.</p>
              </div>
            )}

            {selected.kind === 'trigger.cron' && (
              <Fragment>
                <div class="wizard-field">
                  <label>Schedule (cron)</label>
                  <input class="prop-input" value={selected.params.spec ?? ''}
                    placeholder="min hour dom month dow"
                    onInput={(e) => patchParams(selected.id, { spec: (e.target as HTMLInputElement).value })} />
                </div>
                <div class="wizard-field">
                  <label>Presets</label>
                  <select class="prop-select" value=""
                    onChange={(e) => { const v = (e.target as HTMLSelectElement).value; if (v) patchParams(selected.id, { spec: v }); }}>
                    <option value="">choose…</option>
                    {CRON_PRESETS.map(p => <option key={p.spec} value={p.spec}>{p.label} ({p.spec})</option>)}
                  </select>
                </div>
                <p class="hint">5 fields: minute hour day-of-month month day-of-week. Supports <code>*</code>, <code>*/n</code>, ranges <code>a-b</code> and lists <code>a,b</code>.</p>
              </Fragment>
            )}

            {selected.kind === 'gate.and' && (
              <p class="hint">Wire two or more triggers into this gate. The flow continues only when all
                inputs are satisfied (level triggers like Threshold/Alarm use their current truth).</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'. 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> for a graph-local var
                    or <code>srv:name</code> for a server variable that panels can read.</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="Write to a data source signal, a bare local var, or srv:name for a server variable panels can read. e.g. ({stub:a} - {stub:b}) / {sys:dt}." />
              </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.log' && (
              <Fragment>
                <div class="wizard-field">
                  <label>Label (optional)</label>
                  <input class="prop-input" value={selected.params.label ?? ''}
                    placeholder="prefix for the log 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 server log — handy for debugging a flow." />
              </Fragment>
            )}

            {selected.kind === 'action.lua' && (
              <div class="wizard-field">
                <label>Lua script</label>
                <LuaEditor value={selected.params.script ?? ''}
                  onChange={(v) => patchParams(selected.id, { script: v })} rows={14} />
                <p class="hint">
                  Host functions: <code>get("ds:name")</code> reads a signal/sys/local value,
                  <code>set("ds:name", v)</code> writes (bare name → graph-local var),
                  <code>log(msg)</code> logs to the server. The <code>os/io</code> libraries are disabled.
                </p>
              </div>
            )}

            {selected.kind === 'action.dialog' && (
              <Fragment>
                <div class="wizard-field">
                  <label>Type</label>
                  <select class="prop-input" value={selected.params.kind ?? 'info'}
                    onChange={(e) => patchParams(selected.id, { kind: (e.target as HTMLSelectElement).value })}>
                    <option value="info">Info</option>
                    <option value="error">Error</option>
                    <option value="input">Input (ask for a value)</option>
                  </select>
                </div>
                <div class="wizard-field">
                  <label>Title</label>
                  <input class="prop-input" value={selected.params.title ?? ''}
                    placeholder="dialog title"
                    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="shown to the user"
                    onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
                </div>
                {selected.params.kind === 'input' && (
                  <div class="wizard-field">
                    <label>Response variable</label>
                    <input class="prop-input" value={selected.params.target ?? ''}
                      placeholder="srv:approved"
                      onInput={(e) => patchParams(selected.id, { target: (e.target as HTMLInputElement).value })} />
                    <p class="hint">The user's number is written here (e.g. <code>srv:approved</code>); read it on a later activation.</p>
                  </div>
                )}
                <div class="wizard-field">
                  <label>Users (optional)</label>
                  <input class="prop-input" value={selected.params.users ?? ''}
                    placeholder="alice, bob"
                    onInput={(e) => patchParams(selected.id, { users: (e.target as HTMLInputElement).value })} />
                </div>
                <div class="wizard-field">
                  <label>Groups (optional)</label>
                  <input class="prop-input" value={selected.params.groups ?? ''}
                    placeholder="operators"
                    onInput={(e) => patchParams(selected.id, { groups: (e.target as HTMLInputElement).value })} />
                  <p class="hint">Comma-separated. Leave both empty to show the dialog to every connected user.</p>
                </div>
              </Fragment>
            )}

            {selected.kind === 'action.config.apply' && (
              <div class="wizard-field">
                <label>Config instance</label>
                <select class="prop-select" value={selected.params.instance ?? ''}
                  onChange={(e) => patchParams(selected.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>
                <p class="hint">Applies the instance's current values to every bound signal (like the
                  Apply button in the config manager). Each write is audited.</p>
              </div>
            )}

            {selected.kind === 'action.config.read' && (
              <Fragment>
                <div class="wizard-field">
                  <label>Config instance</label>
                  <select class="prop-select" value={selected.params.instance ?? ''}
                    onChange={(e) => patchParams(selected.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>
                <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. Only
                    numeric values are supported.</p>
                </div>
              </Fragment>
            )}

            {selected.kind === 'action.config.write' && (
              <Fragment>
                <div class="wizard-field">
                  <label>Config instance</label>
                  <select class="prop-select" value={selected.params.instance ?? ''}
                    onChange={(e) => patchParams(selected.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>
                <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>Value expression</label>
                  <input class="prop-input" value={selected.params.expr ?? ''}
                    placeholder="e.g. {ds:signal} * 2"
                    onInput={(e) => patchParams(selected.id, { expr: (e.target as HTMLInputElement).value })} />
                  <p class="hint">Evaluates the expression and stores it into the named parameter, creating a
                    new instance revision. Only numeric values are supported; the value is coerced to the
                    parameter's declared type.</p>
                </div>
              </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>
                  <p class="hint">Creates a new instance for the chosen set, optionally seeded from an existing
                    instance's values. The new id is logged to the audit trail.</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 })} />
                  <p class="hint">Reads the current live value of every target signal of the set and stores
                    them as a new instance. The new id is logged to the audit trail.</p>
                </div>
              </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>
    );
  }
}

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>
  );
}

function nodeSummary(n: CLNode): string {
  switch (n.kind) {
    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.cron':      return n.params.spec || '(no schedule)';
    case 'trigger.alarm':     return `${n.params.signal || '?'} ∉ [${n.params.min || '?'}, ${n.params.max || '?'}]`;
    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.log':        return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
    case 'action.lua':        return 'Lua script';
    case 'action.dialog':     return `${n.params.kind || 'info'} dialog${n.params.title ? ': ' + n.params.title : ''}`;
    default:                  return '';
  }
}
