// Panel logic engine.
//
// A panel may define a LogicGraph (see types.ts): a Node-RED-style flow of
// trigger, gate, control-flow and action nodes connected by wires. This
// singleton engine runs the graph client-side in view mode only: Canvas calls
// `load(iface.logic)` when a panel mounts and `clear()` when it unmounts.
//
// Triggers are flow entry points. When one activates, the engine follows the
// outgoing wires executing downstream nodes:
//   gate.and    — passes only when all incoming triggers are satisfied.
//   flow.if     — evaluates `cond`, continues on the 'then' or 'else' port.
//   flow.loop   — repeats the 'body' port (count / while, capped), then 'done'.
//   action.write— evaluates `expr` and writes the result to `target`.
//   action.delay— awaits `ms` before continuing.
//   action.accumulate / export / clear — collect expression values into a named
//                 in-memory array and download it as CSV.
//   action.log  — logs an expression value to the console.
//
// Expressions reference signals inline as {ds:name} and panel-local vars as
// bare identifiers; their values are read from a live cache kept up to date by
// subscriptions started at load() for every referenced signal. Two built-in
// system signals are served synthetically (never subscribed): {sys:time} is the
// current epoch time in seconds and {sys:dt} is the seconds elapsed since the
// firing trigger last fired (useful in On change / Timer / Panel loop flows,
// e.g. to compute a rate).

import { wsClient } from './ws';
import { getSignalStore } from './stores';
import { writable } from './store';
import { setWidgetDisabled, setWidgetHidden, setPlotPaused, clearPlot, resetWidgetCmds } from './widgetCommands';
import type { SignalRef, SignalValue, LogicGraph, LogicNode } from './types';
import { evalExpr, evalBool, collectRefs, type Resolver } from './expr';

// A single row in a dialog: either an `input` (prompts the operator for a value
// that is later written to its target) or a `display` (a read-only live value
// shown to the operator). A dialog may carry any number of both.
export interface DialogField {
  type: 'input' | 'display';
  label: string;
  // input: the pre-filled default text; display: the resolved value to show.
  value: string;
}

// A pending user-interaction dialog requested by an action.dialog.* node. The
// engine pushes one to `logicDialogs` and awaits the user's response; the
// LogicDialogs component renders it and calls `resolve` with the entered values
// for the input fields (in field order), or null for Cancel. Info/error
// dialogs have no inputs and resolve with an empty array on OK.
export interface DialogRequest {
  id: string;
  kind: 'info' | 'error' | 'setpoint';
  title: string;
  message: string;
  fields: DialogField[];
  resolve: (entered: string[] | null) => void;
}

// Active dialogs, rendered by <LogicDialogs/>. Cleared when the panel unmounts.
export const logicDialogs = writable<DialogRequest[]>([]);

// Guards against runaway flows (cycles / pathological loops).
const MAX_STEPS = 100000;
const MAX_LOOP = 100000;

// Split a "ds:name" reference on the FIRST ':' only (EPICS PV names contain ':').
function parseRef(target: string): SignalRef | null {
  const t = target.trim();
  if (!t) return null;
  const i = t.indexOf(':');
  if (i < 0) return { ds: 'local', name: t }; // bare name → panel-local var
  return { ds: t.slice(0, i), name: t.slice(i + 1) };
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

// Reads a single parameter's value from a config instance, coercing it to a
// number. Returns null if the instance/set/param is missing or non-numeric.
async function readConfigParam(instanceId: string, key: string): Promise<number | null> {
  try {
    const ir = await fetch(`/api/v1/config/instances/${encodeURIComponent(instanceId)}`);
    if (!ir.ok) return null;
    const inst: { setId: string; setVersion?: number; values?: Record<string, any> } = await ir.json();
    const sv = inst.setVersion && inst.setVersion > 0
      ? `/api/v1/config/sets/${encodeURIComponent(inst.setId)}/versions/${inst.setVersion}`
      : `/api/v1/config/sets/${encodeURIComponent(inst.setId)}`;
    const sr = await fetch(sv);
    if (!sr.ok) return null;
    const set: { parameters?: Array<{ key: string; default?: any }> } = await sr.json();
    const param = (set.parameters ?? []).find(p => p.key === key);
    if (!param) return null;
    const raw = inst.values?.[key] ?? param.default;
    const n = toNum(raw);
    return isNaN(n) ? null : n;
  } catch {
    return null;
  }
}

// Write a single numeric value into a config instance's parameter, creating a
// new revision server-side. Reads the current instance, overwrites values[key],
// and PUTs it back. Silently no-ops on any transport/parse error.
async function writeConfigParam(instanceId: string, key: string, val: number): Promise<void> {
  try {
    const ir = await fetch(`/api/v1/config/instances/${encodeURIComponent(instanceId)}`);
    if (!ir.ok) return;
    const inst: any = await ir.json();
    inst.values = { ...(inst.values ?? {}), [key]: val };
    await fetch(`/api/v1/config/instances/${encodeURIComponent(instanceId)}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(inst),
    });
  } catch {}
}

// Create a new config instance for `setId`, optionally seeding its values from
// an existing instance `fromId`. Returns the new instance id, or '' on failure.
async function createConfigInstance(setId: string, name: string, fromId: string): Promise<string> {
  try {
    let values: Record<string, any> = {};
    if (fromId) {
      const fr = await fetch(`/api/v1/config/instances/${encodeURIComponent(fromId)}`);
      if (fr.ok) {
        const src: any = await fr.json();
        values = { ...(src.values ?? {}) };
      }
    }
    const r = await fetch('/api/v1/config/instances', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, setId, values }),
    });
    if (!r.ok) return '';
    const out: any = await r.json();
    return String(out?.id ?? '');
  } catch {
    return '';
  }
}

// Snapshot the current live values of every target signal of `setId` into a new
// config instance (optional label). Returns the new instance id, or '' on failure.
async function snapshotConfigSet(setId: string, name: string): Promise<string> {
  try {
    const r = await fetch(`/api/v1/config/sets/${encodeURIComponent(setId)}/snapshot`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name }),
    });
    if (!r.ok) return '';
    const out: any = await r.json();
    return String(out?.instance?.id ?? '');
  } catch {
    return '';
  }
}

function toNum(v: any): number {
  if (typeof v === 'number') return v;
  if (typeof v === 'boolean') return v ? 1 : 0;
  const n = parseFloat(v);
  return isNaN(n) ? NaN : n;
}

function refKey(ds: string, name: string): string {
  return `${ds}\0${name}`;
}

// A dialog field as configured on a node (before its value is resolved).
interface DialogFieldSpec {
  type: 'input' | 'display';
  label: string;
  target?: string; // input: "ds:name" (or bare local var) to write on OK
  default?: string; // input: expression pre-filling the input
  expr?: string;    // display: expression evaluated and shown
}

// Linearly interpolate a value at time `t` from timestamp-sorted samples. Returns
// null when `t` falls outside the samples' own time range (no extrapolation).
function interpAt(samples: Array<{ t: number; v: number }>, t: number): number | null {
  const n = samples.length;
  if (n === 0 || t < samples[0].t || t > samples[n - 1].t) return null;
  for (let i = 0; i < n - 1; i++) {
    const a = samples[i], b = samples[i + 1];
    if (t >= a.t && t <= b.t) {
      if (b.t === a.t) return a.v;
      return a.v + (b.v - a.v) * (t - a.t) / (b.t - a.t);
    }
  }
  return samples[n - 1].v;
}

// Escape a CSV cell: quote and double inner quotes when it contains a comma,
// quote, or newline.
function csvEsc(s: string): string {
  return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}

interface WireOut { to: string; port: string }
// One flow run, started by an activating trigger. `dt` is the seconds elapsed
// since that trigger last fired (0 on its first fire); `resolve` is an
// expression resolver that exposes the system signals {sys:time}/{sys:dt}.
interface RunCtx { firedTrigger: string; steps: { n: number }; dt: number; resolve: Resolver }

export class LogicEngine {
  private graph: LogicGraph = { nodes: [], wires: [] };
  private byId = new Map<string, LogicNode>();
  // Outgoing wires grouped by source node id.
  private out = new Map<string, WireOut[]>();
  // Incoming source node ids grouped by target node id (for gate evaluation).
  private incoming = new Map<string, string[]>();
  // Live signal value cache (key "ds\0name"), kept fresh by subscriptions.
  private live = new Map<string, any>();
  // Current truth of each level-style trigger (threshold), for AND-gates.
  private levelState = new Map<string, boolean>();
  // Per-node previous values for edge/change detection.
  private prevBool = new Map<string, boolean>();
  private prevVal = new Map<string, any>();
  // signal key → trigger node ids that react to it (threshold / change).
  private watchers = new Map<string, string[]>();
  // Named in-memory data arrays, filled by action.accumulate and dumped by
  // action.export. Each sample keeps the wall-clock time it was recorded.
  private arrays = new Map<string, Array<{ t: number; v: number }>>();
  // Wall-clock time (ms) each trigger last activated, for the {sys:dt} signal.
  private lastFire = new Map<string, number>();
  private cleanups: Array<() => void> = [];

  // Dry-run / debug mode: when true the engine performs no external side effects
  // (signal writes, config mutations, CSV exports, widget commands, dialogs) and
  // instead records each node's last value/firing time so an editor overlay can
  // visualise the flow without touching production. Used by a throwaway engine
  // instance spun up by the Logic editor; the view-mode singleton leaves it off.
  private dryRun = false;
  private debugStates = new Map<string, { value: any; ts: number }>();

  /** Enable/disable dry-run mode. Must be set before load(). */
  setDryRun(on: boolean): void { this.dryRun = on; }

  // Record that a node fired this tick, optionally capturing its computed value.
  private markActive(id: string, value?: any): void {
    if (!this.dryRun) return;
    const prev = this.debugStates.get(id);
    this.debugStates.set(id, { value: value !== undefined ? value : prev?.value, ts: Date.now() });
  }

  /** Per-node debug snapshot: a node is "active" if it fired within ~0.8 s. */
  getDebug(): Map<string, { value: any; active: boolean }> {
    const now = Date.now();
    const out = new Map<string, { value: any; active: boolean }>();
    for (const [id, s] of this.debugStates) out.set(id, { value: s.value, active: now - s.ts < 800 });
    return out;
  }

  // Suppress signal writes in dry-run; otherwise forward to the live client.
  private dryWrite(ref: SignalRef, val: any): void {
    if (!this.dryRun) wsClient.write(ref, val);
  }

  // Resolve an expression reference. Two built-in system signals are served
  // synthetically (never subscribed): {sys:time} = current epoch seconds,
  // {sys:dt} = seconds since the firing trigger last fired. Everything else
  // reads from the live signal cache.
  private sysResolve(ds: string, name: string, dt: number): number {
    if (ds === 'sys') {
      if (name === 'time') return Date.now() / 1000;
      if (name === 'dt') return dt;
      return NaN;
    }
    return toNum(this.live.get(refKey(ds, name)));
  }

  // Resolve the config-instance id a node operates on. With instanceSource
  // 'var' the id is read (as a raw string) from a panel-local variable / signal
  // — this is how a config-selector widget feeds the apply/read/write nodes.
  // Otherwise the fixed `instance` param (an id chosen at design time) is used.
  private resolveInstanceId(node: LogicNode): string {
    if ((node.params.instanceSource ?? 'fixed') === 'var') {
      const r = parseRef(node.params.instanceVar ?? '');
      if (!r) return '';
      const v = this.live.get(refKey(r.ds, r.name));
      return v == null ? '' : String(v).trim();
    }
    return (node.params.instance ?? '').trim();
  }

  /** Activate a panel's logic graph. Replaces any previously loaded graph. */
  load(graph: LogicGraph | undefined): void {
    this.clear();
    this.graph = graph ?? { nodes: [], wires: [] };

    this.byId = new Map(this.graph.nodes.map(n => [n.id, n]));
    this.out = new Map();
    this.incoming = new Map();
    for (const w of this.graph.wires) {
      (this.out.get(w.from) ?? this.out.set(w.from, []).get(w.from)!)
        .push({ to: w.to, port: w.fromPort ?? 'out' });
      (this.incoming.get(w.to) ?? this.incoming.set(w.to, []).get(w.to)!)
        .push(w.from);
    }

    this.subscribeRefs();

    for (const node of this.graph.nodes) {
      switch (node.kind) {
        case 'trigger.timer': {
          const id = setInterval(() => this.activate(node.id), this.intervalOf(node));
          this.cleanups.push(() => clearInterval(id));
          break;
        }
        case 'trigger.loop': {
          this.activate(node.id); // run once on load
          const id = setInterval(() => this.activate(node.id), this.intervalOf(node));
          this.cleanups.push(() => clearInterval(id));
          break;
        }
        // button/threshold/change are driven imperatively / by subscriptions.
      }
    }

    // Panel-open lifecycle: fire every "On open" trigger once now that the
    // graph is wired and subscriptions are live.
    for (const node of this.graph.nodes) {
      if (node.kind === 'trigger.start') this.activate(node.id);
    }
  }

  /** Deactivate all triggers and subscriptions. Called when a panel unmounts. */
  clear(): void {
    // Panel-close lifecycle: fire every "On close" trigger before tearing down,
    // while the live cache and wiring are still intact (synchronous writes run
    // immediately; anything past an await is truncated as the graph resets).
    for (const node of this.graph.nodes) {
      if (node.kind === 'trigger.stop') this.activate(node.id);
    }
    // Dismiss any dialogs left open by the panel being closed (resolve null).
    for (const d of logicDialogs.get()) { try { d.resolve(null); } catch {} }
    logicDialogs.set([]);

    for (const c of this.cleanups) { try { c(); } catch {} }
    this.cleanups = [];
    this.graph = { nodes: [], wires: [] };
    this.byId = new Map();
    this.out = new Map();
    this.incoming = new Map();
    this.live = new Map();
    this.levelState = new Map();
    this.prevBool = new Map();
    this.prevVal = new Map();
    this.watchers = new Map();
    this.arrays = new Map();
    this.lastFire = new Map();
    this.debugStates = new Map();
    // Restore every widget to its default state so reopening a panel (whose
    // widget ids are stable) does not inherit disabled/hidden/paused flags. The
    // dry-run editor engine must not touch the real widget command stores.
    if (!this.dryRun) resetWidgetCmds();
  }

  /** Fire every button-trigger node whose `name` param matches. */
  runAction(name: string): void {
    if (!name) return;
    for (const node of this.graph.nodes) {
      if (node.kind === 'trigger.button' && (node.params.name ?? '') === name) {
        this.activate(node.id);
      }
    }
  }

  // ── Subscriptions ──────────────────────────────────────────────────────────

  // Subscribe to every signal referenced anywhere in the graph (explicit
  // threshold/change signals + inline {ds:name}/local refs in expressions),
  // feeding the live cache and waking threshold/change triggers.
  private subscribeRefs(): void {
    const refs = new Map<string, SignalRef>();
    // {sys:*} signals are served synthetically by sysResolve — never subscribed.
    const want = (r: SignalRef) => { if (r.ds === 'sys') return; if (r.name || r.ds === 'local') refs.set(refKey(r.ds, r.name), r); };

    for (const node of this.graph.nodes) {
      switch (node.kind) {
        case 'trigger.threshold':
        case 'trigger.change': {
          const r = parseRef(node.params.signal ?? '');
          if (r) {
            want(r);
            const key = refKey(r.ds, r.name);
            (this.watchers.get(key) ?? this.watchers.set(key, []).get(key)!).push(node.id);
          }
          break;
        }
        case 'flow.if':         collectRefs(node.params.cond ?? '').forEach(want); break;
        case 'flow.loop':       if ((node.params.mode ?? 'count') === 'while') collectRefs(node.params.cond ?? '').forEach(want); break;
        case 'action.write':
        case 'action.accumulate':
        case 'action.log':      collectRefs(node.params.expr ?? '').forEach(want); break;
        case 'action.config.apply':
        case 'action.config.read':
        case 'action.config.write': {
          // Dynamic-instance nodes read their target instance id from a panel
          // var; subscribe it so the live cache holds the selected id.
          if ((node.params.instanceSource ?? 'fixed') === 'var') {
            const r = parseRef(node.params.instanceVar ?? '');
            if (r) want(r);
          }
          if (node.kind === 'action.config.write') collectRefs(node.params.expr ?? '').forEach(want);
          break;
        }
        case 'action.dialog.info':
        case 'action.dialog.error':
        case 'action.dialog.setpoint': {
          this.messageRefs(node.params.message ?? '').forEach(want);
          this.messageRefs(node.params.title ?? '').forEach(want);
          for (const f of this.dialogFieldSpecs(node)) {
            if (f.type === 'display') collectRefs(f.expr ?? '').forEach(want);
            else collectRefs(f.default ?? '').forEach(want);
          }
          break;
        }
      }
    }

    for (const [key, ref] of refs) {
      const unsub = getSignalStore(ref).subscribe((v: SignalValue) => {
        this.live.set(key, v.value);
        this.onSignal(key, v.value);
      });
      this.cleanups.push(unsub);
    }
  }

  // Drive threshold (rising edge) and change triggers when a watched signal updates.
  private onSignal(key: string, value: any): void {
    for (const id of this.watchers.get(key) ?? []) {
      const node = this.byId.get(id);
      if (!node) continue;
      if (node.kind === 'trigger.threshold') {
        const cur = this.testThreshold(toNum(value), node.params.op ?? '>', toNum(node.params.value ?? '0'));
        this.levelState.set(id, cur);
        if (cur && !(this.prevBool.get(id) ?? false)) this.activate(id);
        this.prevBool.set(id, cur);
      } else if (node.kind === 'trigger.change') {
        const had = this.prevVal.has(id);
        const prev = this.prevVal.get(id);
        this.prevVal.set(id, value);
        if (had && value !== prev) this.activate(id);
      }
    }
  }

  private testThreshold(val: number, op: string, cmp: number): boolean {
    if (isNaN(val)) return false;
    switch (op) {
      case '>': return val > cmp;
      case '<': return val < cmp;
      case '>=': return val >= cmp;
      case '<=': return val <= cmp;
      case '==': return val === cmp;
      case '!=': return val !== cmp;
      default: return false;
    }
  }

  private intervalOf(node: LogicNode): number {
    return Math.max(50, parseInt(node.params.interval ?? '1000', 10) || 1000);
  }

  // ── Execution ───────────────────────────────────────────────────────────────

  /** Manually fire a trigger node (debug mode: double-click to force a flow
   *  run). No-op if the id isn't a trigger node. */
  fireTrigger(nodeId: string): boolean {
    const node = this.byId.get(nodeId);
    if (!node || !node.kind.startsWith('trigger.')) return false;
    this.activate(nodeId);
    return true;
  }

  // A trigger activated: walk its outgoing 'out' wires.
  private activate(triggerId: string): void {
    const now = Date.now();
    const last = this.lastFire.get(triggerId);
    const dt = last === undefined ? 0 : (now - last) / 1000;
    this.lastFire.set(triggerId, now);
    this.markActive(triggerId);
    const ctx: RunCtx = {
      firedTrigger: triggerId,
      steps: { n: 0 },
      dt,
      resolve: (ds, name) => this.sysResolve(ds, name, dt),
    };
    void this.follow(triggerId, 'out', ctx);
  }

  // Run every wire leaving `fromId` on output port `port`.
  private async follow(fromId: string, port: string, ctx: RunCtx): Promise<void> {
    for (const w of this.out.get(fromId) ?? []) {
      if (w.port === port) await this.run(w.to, ctx);
    }
  }

  // Execute one node, then continue downstream as appropriate.
  private async run(nodeId: string, ctx: RunCtx): Promise<void> {
    if (ctx.steps.n++ > MAX_STEPS) return;
    const node = this.byId.get(nodeId);
    if (!node) return;
    this.markActive(node.id);

    switch (node.kind) {
      case 'gate.and': {
        const ok = this.gateSatisfied(node.id, ctx.firedTrigger);
        this.markActive(node.id, ok);
        if (ok) await this.follow(node.id, 'out', ctx);
        return;
      }

      case 'flow.if': {
        const pass = evalBool(node.params.cond ?? '', ctx.resolve);
        this.markActive(node.id, pass);
        await this.follow(node.id, pass ? 'then' : 'else', ctx);
        return;
      }

      case 'flow.loop': {
        const mode = node.params.mode ?? 'count';
        if (mode === 'count') {
          const n = Math.min(MAX_LOOP, Math.max(0, Math.floor(toNum(node.params.count ?? '0')) || 0));
          for (let i = 0; i < n && ctx.steps.n <= MAX_STEPS; i++) await this.follow(node.id, 'body', ctx);
        } else {
          let i = 0;
          while (i < MAX_LOOP && ctx.steps.n <= MAX_STEPS && evalBool(node.params.cond ?? '', ctx.resolve)) {
            await this.follow(node.id, 'body', ctx);
            i++;
          }
        }
        await this.follow(node.id, 'done', ctx);
        return;
      }

      case 'action.write': {
        const ref = parseRef(node.params.target ?? '');
        const val = evalExpr(node.params.expr ?? '', ctx.resolve);
        this.markActive(node.id, val);
        if (ref && !isNaN(val)) this.dryWrite(ref, val);
        await this.follow(node.id, 'out', ctx);
        return;
      }

      case 'action.config.apply': {
        const id = this.resolveInstanceId(node);
        if (id && !this.dryRun) {
          try {
            await fetch(`/api/v1/config/instances/${encodeURIComponent(id)}/apply`, { method: 'POST' });
          } catch {}
        }
        await this.follow(node.id, 'out', ctx);
        return;
      }

      case 'action.config.read': {
        const id = this.resolveInstanceId(node);
        const key = (node.params.key ?? '').trim();
        const ref = parseRef(node.params.target ?? '');
        if (id && key && ref && !this.dryRun) {
          const val = await readConfigParam(id, key);
          if (val !== null && !isNaN(val)) this.dryWrite(ref, val);
        }
        await this.follow(node.id, 'out', ctx);
        return;
      }

      case 'action.config.write': {
        const id = this.resolveInstanceId(node);
        const key = (node.params.key ?? '').trim();
        const val = evalExpr(node.params.expr ?? '', ctx.resolve);
        if (id && key && !isNaN(val) && !this.dryRun) {
          await writeConfigParam(id, key, val);
        }
        await this.follow(node.id, 'out', ctx);
        return;
      }

      case 'action.config.create': {
        const setId = (node.params.set ?? '').trim();
        const name = (node.params.name ?? '').trim() || 'auto';
        const fromId = (node.params.from ?? '').trim();
        const target = parseRef(node.params.target ?? '');
        if (setId && !this.dryRun) {
          const newId = await createConfigInstance(setId, name, fromId);
          if (newId && target) this.dryWrite(target, newId);
        }
        await this.follow(node.id, 'out', ctx);
        return;
      }

      case 'action.config.snapshot': {
        const setId = (node.params.set ?? '').trim();
        const name = (node.params.name ?? '').trim();
        const target = parseRef(node.params.target ?? '');
        if (setId && !this.dryRun) {
          const newId = await snapshotConfigSet(setId, name);
          if (newId && target) this.dryWrite(target, newId);
        }
        await this.follow(node.id, 'out', ctx);
        return;
      }

      case 'action.delay':
        await sleep(Math.max(0, parseInt(node.params.ms ?? '0', 10) || 0));
        await this.follow(node.id, 'out', ctx);
        return;

      case 'action.accumulate': {
        const name = (node.params.array ?? '').trim();
        const val = evalExpr(node.params.expr ?? '', ctx.resolve);
        this.markActive(node.id, val);
        if (name && !isNaN(val)) {
          const arr = this.arrays.get(name) ?? this.arrays.set(name, []).get(name)!;
          arr.push({ t: Date.now(), v: val });
        }
        await this.follow(node.id, 'out', ctx);
        return;
      }

      case 'action.export': {
        if (!this.dryRun) this.exportArrays(this.exportColumns(node), node.params.align ?? 'common', node.params.filename ?? '');
        await this.follow(node.id, 'out', ctx);
        return;
      }

      case 'action.clear': {
        const name = (node.params.array ?? '').trim();
        if (name) this.arrays.delete(name);
        await this.follow(node.id, 'out', ctx);
        return;
      }

      case 'action.log': {
        const val = evalExpr(node.params.expr ?? '', ctx.resolve);
        const label = (node.params.label ?? '').trim();
        this.markActive(node.id, val);
        console.log(`[logic]${label ? ' ' + label + ':' : ''}`, val);
        await this.follow(node.id, 'out', ctx);
        return;
      }

      case 'action.widget': {
        const wid = (node.params.widget ?? '').trim();
        if (wid && !this.dryRun) {
          switch (node.params.op ?? '') {
            case 'enable':     setWidgetDisabled(wid, false); break;
            case 'disable':    setWidgetDisabled(wid, true); break;
            case 'show':       setWidgetHidden(wid, false); break;
            case 'hide':       setWidgetHidden(wid, true); break;
            case 'pauseplot':  setPlotPaused(wid, true); break;
            case 'resumeplot': setPlotPaused(wid, false); break;
            case 'clearplot':  clearPlot(wid); break;
          }
        }
        await this.follow(node.id, 'out', ctx);
        return;
      }

      case 'action.dialog.info':
      case 'action.dialog.error': {
        if (this.dryRun) { await this.follow(node.id, 'out', ctx); return; }
        const specs = this.dialogFieldSpecs(node);
        await this.showDialog({
          kind: node.kind === 'action.dialog.error' ? 'error' : 'info',
          title: this.interpolate(node.params.title ?? '', ctx.resolve),
          message: this.interpolate(node.params.message ?? '', ctx.resolve),
          fields: this.dialogFieldViews(specs, ctx),
        });
        await this.follow(node.id, 'out', ctx);
        return;
      }

      case 'action.dialog.setpoint': {
        if (this.dryRun) { await this.follow(node.id, 'out', ctx); return; }
        const specs = this.dialogFieldSpecs(node);
        const result = await this.showDialog({
          kind: 'setpoint',
          title: this.interpolate(node.params.title ?? '', ctx.resolve),
          message: this.interpolate(node.params.message ?? '', ctx.resolve),
          fields: this.dialogFieldViews(specs, ctx),
        });
        // Cancel (null) aborts the flow; OK writes every input value and continues.
        if (result === null) return;
        let i = 0;
        for (const spec of specs) {
          if (spec.type !== 'input') continue;
          const ref = parseRef(spec.target ?? '');
          const num = parseFloat(result[i++] ?? '');
          if (ref && !isNaN(num)) wsClient.write(ref, num);
        }
        await this.follow(node.id, 'out', ctx);
        return;
      }

      default:
        // A trigger reached mid-walk (unusual): just pass through.
        await this.follow(node.id, 'out', ctx);
    }
  }

  // The columns an action.export node should emit. Reads the JSON `columns`
  // param (a list of { array, label }); falls back to the legacy single `array`.
  private exportColumns(node: LogicNode): Array<{ array: string; label: string }> {
    const raw = (node.params.columns ?? '').trim();
    if (raw) {
      try {
        const parsed = JSON.parse(raw);
        if (Array.isArray(parsed)) {
          return parsed
            .map((c: any) => ({ array: String(c?.array ?? '').trim(), label: String(c?.label ?? '').trim() }))
            .filter(c => c.array);
        }
      } catch {}
    }
    const a = (node.params.array ?? '').trim();
    return a ? [{ array: a, label: a }] : [];
  }

  // Dump one or more named arrays to a CSV the browser downloads. Each column is
  // a captured array; rows are keyed by sample timestamp. `align` controls how
  // arrays with differing timestamps are combined:
  //   common      — only timestamps present in every array (intersection).
  //   any         — the union of all timestamps; cells with no sample are blank.
  //   interpolate — the union of all timestamps; a missing cell is linearly
  //                 interpolated between the array's surrounding samples (blank
  //                 outside the array's own time range).
  private exportArrays(cols: Array<{ array: string; label: string }>, align: string, filename: string): void {
    if (cols.length === 0) return;
    const series = cols.map((c, i) => ({
      label: c.label || c.array || `col${i + 1}`,
      samples: (this.arrays.get(c.array) ?? []).slice().sort((a, b) => a.t - b.t),
      map: new Map<number, number>(),
    }));
    for (const s of series) for (const p of s.samples) s.map.set(p.t, p.v);

    const tsSet = new Set<number>();
    for (const s of series) for (const p of s.samples) tsSet.add(p.t);
    let times = Array.from(tsSet).sort((a, b) => a - b);
    if (align === 'common') times = times.filter(t => series.every(s => s.map.has(t)));

    const cell = (s: typeof series[number], t: number): string => {
      const exact = s.map.get(t);
      if (exact !== undefined) return String(exact);
      if (align === 'interpolate') {
        const v = interpAt(s.samples, t);
        return v === null ? '' : String(v);
      }
      return ''; // 'any' (and 'common' never reaches here)
    };

    const header = ['timestamp_ms', 'iso', ...series.map(s => s.label)];
    const rows = times.map(t => [String(t), new Date(t).toISOString(), ...series.map(s => cell(s, t))]);
    const csv = [header, ...rows].map(r => r.map(csvEsc).join(',')).join('\n') + '\n';

    const base = filename || series.map(s => s.label).join('_') || 'export';
    const safe = base.replace(/[^a-z0-9_.-]/gi, '_');
    const fname = safe.toLowerCase().endsWith('.csv') ? safe : `${safe}.csv`;
    const blob = new Blob([csv], { type: 'text/csv' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = fname;
    a.click();
    URL.revokeObjectURL(url);
  }

  // ── Dialogs (user interaction) ───────────────────────────────────────────────

  // The field specs of a dialog node. Reads the JSON `fields` param (a list of
  // { type:'input'|'display', label, target?, default?, expr? }); for a legacy
  // set-point node with no `fields` it falls back to the single target/default.
  private dialogFieldSpecs(node: LogicNode): DialogFieldSpec[] {
    const raw = (node.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 (node.kind === 'action.dialog.setpoint') {
      return [{ type: 'input', label: '', target: node.params.target ?? '', default: node.params.default ?? '', expr: '' }];
    }
    return [];
  }

  // Resolve a node's field specs into displayable views: display fields get their
  // expression evaluated to text; input fields get their default expression
  // evaluated to a pre-fill string ('' when no default is set).
  private dialogFieldViews(specs: DialogFieldSpec[], ctx: RunCtx): DialogField[] {
    return specs.map(s => {
      if (s.type === 'display') {
        const v = evalExpr(s.expr ?? '', ctx.resolve);
        return { type: 'display' as const, label: s.label, value: isNaN(v) ? '?' : String(v) };
      }
      const def = (s.default ?? '').trim() ? String(evalExpr(s.default ?? '', ctx.resolve)) : '';
      return { type: 'input' as const, label: s.label, value: def };
    });
  }

  // Push a dialog request to the shared store and resolve when the user responds
  // (the entered input values on OK, null on Cancel).
  private showDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<string[] | null> {
    return new Promise((resolve) => {
      const id = 'dlg_' + Math.random().toString(36).slice(2, 9);
      const full: DialogRequest = {
        ...req,
        id,
        resolve: (r) => { logicDialogs.update(list => list.filter(d => d.id !== id)); resolve(r); },
      };
      logicDialogs.update(list => [...list, full]);
    });
  }

  // Substitute {ds:name} (and {sys:*}) tokens in dialog text with live values.
  private interpolate(text: string, resolve: Resolver): string {
    return (text ?? '').replace(/\{([^}]*)\}/g, (_m, inner) => {
      const v = evalExpr('{' + inner + '}', resolve);
      return isNaN(v) ? '?' : String(v);
    });
  }

  // Signals referenced by {…} tokens in dialog message/title text.
  private messageRefs(text: string): SignalRef[] {
    const out: SignalRef[] = [];
    for (const m of (text ?? '').matchAll(/\{([^}]*)\}/g)) {
      const r = parseRef(m[1]);
      if (r) out.push(r);
    }
    return out;
  }

  // An AND-gate fires when every incoming trigger is currently satisfied: the
  // activating trigger counts as satisfied, and level triggers (threshold) use
  // their current truth. Momentary triggers that are not firing now are false.
  private gateSatisfied(gateId: string, firedTrigger: string): boolean {
    const inputs = this.incoming.get(gateId) ?? [];
    if (inputs.length === 0) return false;
    return inputs.every(src => src === firedTrigger || this.levelState.get(src) === true);
  }
}

// Singleton instance, mirrored on wsClient's module-level pattern.
export const logicEngine = new LogicEngine();
