package controllogic

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"math"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/uopi/uopi/internal/audit"
	"github.com/uopi/uopi/internal/broker"
	"github.com/uopi/uopi/internal/confmgr"
)

// errUnknownSource is returned by config-apply writes targeting a data source
// that isn't registered with the broker.
var errUnknownSource = errors.New("unknown data source")

// formatAny renders a config value for the audit log.
func formatAny(v any) string {
	switch x := v.(type) {
	case float64:
		return strconv.FormatFloat(x, 'g', -1, 64)
	case string:
		return x
	default:
		return fmt.Sprintf("%v", v)
	}
}

// Guards against runaway flows (cycles / pathological loops).
const (
	maxSteps = 100000
	maxLoop  = 100000
)

// Engine runs all enabled control-logic graphs continuously under a root
// context. Reload tears down the current generation (subscriptions, timers,
// in-flight flows) and rebuilds from the store's enabled graphs.
type Engine struct {
	broker *broker.Broker
	store  *Store
	cfg    *confmgr.Store
	audit  audit.Recorder
	log    *slog.Logger
	root   context.Context

	mu     sync.Mutex
	cancel context.CancelFunc // cancels the current generation
	wg     *sync.WaitGroup    // tracks the current generation's goroutines

	// notifier delivers action.dialog requests to connected clients. Stored in
	// an atomic so flow goroutines can read it without taking e.mu (which Reload
	// holds while it waits for those same goroutines to drain).
	notifier atomic.Value // notifierBox

	// debugObs receives per-node execution events for the live debug view, and
	// debugWatch is the set of graph ids with at least one live subscriber. Both
	// are read lock-free by flow goroutines (same trick as notifier); emitDebug
	// short-circuits cheaply when the firing graph has no watcher.
	debugObs   atomic.Value // debugObsBox
	debugWatch atomic.Value // map[string]bool (immutable snapshot)

	// fireChs maps a debug route id (live graph id or simulate sandbox id) to its
	// compiled graph's manual-fire channel, so the debug UI can force a trigger to
	// run. Entries are added/removed as generations (and simulate sandboxes) come
	// and go; FireTrigger does a non-blocking send so a torn-down graph drops.
	fireMu  sync.Mutex
	fireChs map[string]chan string

	// Shared live signal cache for the current generation (key "ds\0name").
	liveMu sync.RWMutex
	live   map[string]float64
}

// notifierBox wraps a Notifier so atomic.Value always sees one concrete type.
type notifierBox struct{ n Notifier }

// dialogSeq generates unique action.dialog ids across all graphs.
var dialogSeq uint64

// SetNotifier installs the sink for action.dialog requests. Safe to call once
// at startup before or after Reload; it is read lock-free by running flows.
func (e *Engine) SetNotifier(n Notifier) {
	e.notifier.Store(notifierBox{n: n})
}

// NewEngine creates an engine bound to root. Call Reload to start it. rec records
// the writes performed by flows; pass audit.Nop() to disable auditing.
func NewEngine(root context.Context, brk *broker.Broker, store *Store, cfg *confmgr.Store, rec audit.Recorder, log *slog.Logger) *Engine {
	if rec == nil {
		rec = audit.Nop()
	}
	return &Engine{
		broker:  brk,
		store:   store,
		cfg:     cfg,
		audit:   rec,
		log:     log,
		root:    root,
		live:    map[string]float64{},
		fireChs: map[string]chan string{},
	}
}

// ── reference / value helpers ──────────────────────────────────────────────────

func refKey(ds, name string) string { return ds + "\x00" + name }

// parseRef splits a "ds:name" target on the FIRST ':' (EPICS PV names contain
// ':'). A bare name (no ':') is a graph-local variable in data source "local".
func parseRef(target string) (ds, name string, ok bool) {
	t := strings.TrimSpace(target)
	if t == "" {
		return "", "", false
	}
	i := strings.IndexByte(t, ':')
	if i < 0 {
		return "local", t, true
	}
	return t[:i], t[i+1:], true
}

func toNum(v any) float64 {
	switch x := v.(type) {
	case float64:
		return x
	case float32:
		return float64(x)
	case int64:
		return float64(x)
	case int:
		return float64(x)
	case bool:
		if x {
			return 1
		}
		return 0
	case string:
		f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
		if err != nil {
			return math.NaN()
		}
		return f
	default:
		return math.NaN()
	}
}

// ── lifecycle ───────────────────────────────────────────────────────────────

// Reload rebuilds the engine from the store. Safe to call repeatedly (after any
// graph mutation). It is a no-op-safe full restart of the running generation.
func (e *Engine) Reload() {
	e.mu.Lock()
	defer e.mu.Unlock()

	// Tear down the previous generation and wait for its goroutines to exit.
	if e.cancel != nil {
		e.cancel()
		e.cancel = nil
	}
	if e.wg != nil {
		e.wg.Wait()
		e.wg = nil
	}

	e.liveMu.Lock()
	e.live = map[string]float64{}
	e.liveMu.Unlock()

	graphs := e.store.List()
	var compiled []*compiledGraph
	refs := map[string]RefLite{}
	for i := range graphs {
		g := graphs[i]
		if !g.Enabled {
			continue
		}
		cg := compile(g)
		compiled = append(compiled, cg)
		for k, r := range cg.refs {
			refs[k] = r
		}
	}
	if len(compiled) == 0 {
		return
	}

	genCtx, cancel := context.WithCancel(e.root)
	wg := &sync.WaitGroup{}
	e.cancel = cancel
	e.wg = wg

	for _, cg := range compiled {
		cg.engine = e
		cg.genCtx = genCtx
		cg.wg = wg
		e.registerFire(cg.id, cg.fireCh)
	}

	// One shared updates channel feeds a single dispatch goroutine; every
	// subscription delivers into it. Subscriptions are released on teardown.
	updates := make(chan broker.Update, 128)
	var unsubs []func()
	for _, r := range refs {
		unsub, err := e.broker.Subscribe(broker.SignalRef{DS: r.DS, Name: r.Name}, updates)
		if err != nil {
			e.log.Warn("control logic: subscribe failed", "ds", r.DS, "signal", r.Name, "err", err)
			continue
		}
		unsubs = append(unsubs, unsub)
	}

	wg.Add(1)
	go func() {
		defer wg.Done()
		<-genCtx.Done()
		for _, u := range unsubs {
			u()
		}
		for _, cg := range compiled {
			e.unregisterFire(cg.id, cg.fireCh)
		}
	}()

	// Dispatch goroutine: keep the live cache fresh and drive level/edge triggers.
	wg.Add(1)
	go func() {
		defer wg.Done()
		for {
			select {
			case u := <-updates:
				val := toNum(u.Value.Data)
				key := refKey(u.Ref.DS, u.Ref.Name)
				e.liveMu.Lock()
				e.live[key] = val
				e.liveMu.Unlock()
				for _, cg := range compiled {
					cg.onSignal(key, val)
				}
			case <-genCtx.Done():
				return
			}
		}
	}()

	// Start timer and cron triggers.
	for _, cg := range compiled {
		cg.startTriggers()
	}

	e.log.Info("control logic engine reloaded", "graphs", len(compiled), "signals", len(refs))
}

// liveGet reads the current value of a signal from the shared cache.
func (e *Engine) liveGet(ds, name string) float64 {
	if ds == "sys" {
		if name == "time" {
			return float64(time.Now().UnixNano()) / 1e9
		}
		return math.NaN() // sys:dt handled per-activation
	}
	e.liveMu.RLock()
	defer e.liveMu.RUnlock()
	v, ok := e.live[refKey(ds, name)]
	if !ok {
		return math.NaN()
	}
	return v
}

// write applies an action.write/lua-set to a target: a bare name updates a
// graph-local var; a ds:name target writes to the data source.
func (e *Engine) write(cg *compiledGraph, target string, val float64) {
	ds, name, ok := parseRef(target)
	if !ok || math.IsNaN(val) {
		return
	}
	if ds == "local" {
		cg.setLocal(name, val)
		return
	}
	if cg.dryRun {
		return // simulate: no real data-source write
	}
	src, ok := e.broker.Source(ds)
	if !ok {
		e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
		return
	}
	ev := audit.Event{
		Actor:     cg.name,
		ActorType: audit.ActorSystem,
		Action:    "signal.write",
		DS:        ds,
		Signal:    name,
		Value:     strconv.FormatFloat(val, 'g', -1, 64),
		Detail:    "control logic: " + cg.name,
		Outcome:   audit.OutcomeOK,
	}
	if err := src.Write(e.root, name, val); err != nil {
		e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
		ev.Outcome = audit.OutcomeError
		ev.Error = err.Error()
	}
	e.audit.Record(ev)
}

// applyConfig resolves a config instance + its set and writes every parameter
// to its target signal via the owning data source (confmgr.Apply). Each write is
// audited. A bare instance id or a missing config store is a no-op (logged).
func (e *Engine) applyConfig(cg *compiledGraph, instanceID string) {
	if e.cfg == nil || instanceID == "" {
		return
	}
	inst, err := e.cfg.GetInstance(instanceID)
	if err != nil {
		e.log.Warn("control logic: config apply: unknown instance", "instance", instanceID, "err", err)
		return
	}
	set, err := e.cfg.SetForInstance(inst)
	if err != nil {
		e.log.Warn("control logic: config apply: set lookup failed", "instance", instanceID, "err", err)
		return
	}
	write := func(ds, signal string, value any) error {
		ev := audit.Event{
			Actor:     cg.name,
			ActorType: audit.ActorSystem,
			Action:    "signal.write",
			DS:        ds,
			Signal:    signal,
			Value:     formatAny(value),
			Detail:    "control logic config apply: " + inst.Name,
			Outcome:   audit.OutcomeOK,
		}
		var werr error
		if ds == "local" {
			cg.setLocal(signal, toNum(value))
		} else if src, ok := e.broker.Source(ds); ok {
			werr = src.Write(e.root, signal, value)
		} else {
			werr = errUnknownSource
		}
		if werr != nil {
			ev.Outcome = audit.OutcomeError
			ev.Error = werr.Error()
		}
		e.audit.Record(ev)
		return werr
	}
	confmgr.Apply(set, inst, write)
}

// readConfigParam resolves a single parameter's value from a config instance,
// coercing it to float64 for use as a control-logic value. Returns ok=false if
// the store/instance/param is missing or the value isn't numeric.
func (e *Engine) readConfigParam(instanceID, key string) (float64, bool) {
	if e.cfg == nil || instanceID == "" || key == "" {
		return 0, false
	}
	inst, err := e.cfg.GetInstance(instanceID)
	if err != nil {
		e.log.Warn("control logic: config read: unknown instance", "instance", instanceID, "err", err)
		return 0, false
	}
	set, err := e.cfg.SetForInstance(inst)
	if err != nil {
		e.log.Warn("control logic: config read: set lookup failed", "instance", instanceID, "err", err)
		return 0, false
	}
	for _, p := range set.Parameters {
		if p.Key != key {
			continue
		}
		v, ok := inst.Resolve(p)
		if !ok {
			return 0, false
		}
		f := toNum(v)
		if math.IsNaN(f) {
			return 0, false
		}
		return f, true
	}
	return 0, false
}

// writeConfigParam sets a single parameter's value on a config instance and
// saves a new revision (git-style). The value is coerced to the parameter's
// declared type (numeric/bool/string) so it validates against the set. A
// missing store/instance/param is a no-op (logged). Audited.
func (e *Engine) writeConfigParam(cg *compiledGraph, instanceID, key string, val float64) {
	if e.cfg == nil || instanceID == "" || key == "" {
		return
	}
	inst, err := e.cfg.GetInstance(instanceID)
	if err != nil {
		e.log.Warn("control logic: config write: unknown instance", "instance", instanceID, "err", err)
		return
	}
	set, err := e.cfg.SetForInstance(inst)
	if err != nil {
		e.log.Warn("control logic: config write: set lookup failed", "instance", instanceID, "err", err)
		return
	}
	var param *confmgr.Parameter
	for i := range set.Parameters {
		if set.Parameters[i].Key == key {
			param = &set.Parameters[i]
			break
		}
	}
	if param == nil {
		e.log.Warn("control logic: config write: unknown parameter", "instance", instanceID, "key", key)
		return
	}
	if inst.Values == nil {
		inst.Values = map[string]any{}
	}
	inst.Values[key] = coerceParamValue(*param, val)
	ev := audit.Event{
		Actor:     cg.name,
		ActorType: audit.ActorSystem,
		Action:    "config.instance.update",
		Detail:    "control logic config write: " + inst.Name + " " + key + "=" + formatAny(inst.Values[key]),
		Outcome:   audit.OutcomeOK,
	}
	if _, err := e.cfg.UpdateInstance(instanceID, inst, ""); err != nil {
		e.log.Warn("control logic: config write failed", "instance", instanceID, "key", key, "err", err)
		ev.Outcome = audit.OutcomeError
		ev.Error = err.Error()
	}
	e.audit.Record(ev)
}

// createConfigInstance creates a new config instance for setID, optionally
// copying values from an existing instance (fromID). A missing store/set is a
// no-op (logged). Audited. The new instance's id is logged but not written back
// to a flow variable (control-logic variables are numeric, ids are strings).
func (e *Engine) createConfigInstance(cg *compiledGraph, setID, name, fromID string) {
	if e.cfg == nil || setID == "" {
		return
	}
	if name == "" {
		name = "auto"
	}
	values := map[string]any{}
	if fromID != "" {
		if src, err := e.cfg.GetInstance(fromID); err == nil {
			for k, v := range src.Values {
				values[k] = v
			}
		} else {
			e.log.Warn("control logic: config create: copy source missing", "from", fromID, "err", err)
		}
	}
	inst := confmgr.ConfigInstance{Name: name, SetID: setID, Values: values}
	ev := audit.Event{
		Actor:     cg.name,
		ActorType: audit.ActorSystem,
		Action:    "config.instance.create",
		Detail:    "control logic config create: set=" + setID + " name=" + name,
		Outcome:   audit.OutcomeOK,
	}
	out, err := e.cfg.CreateInstance(inst, "")
	if err != nil {
		e.log.Warn("control logic: config create failed", "set", setID, "err", err)
		ev.Outcome = audit.OutcomeError
		ev.Error = err.Error()
	} else {
		ev.Detail += " -> " + out.ID
	}
	e.audit.Record(ev)
}

// snapshotConfig captures the current value of every target signal of a set and
// stores them as a new config instance. A missing store/set is a no-op (logged).
// Audited. The new instance's id is logged but not written back to a flow
// variable (control-logic variables are numeric, ids are strings).
func (e *Engine) snapshotConfig(cg *compiledGraph, setID, name string) {
	if e.cfg == nil || setID == "" {
		return
	}
	set, err := e.cfg.GetSet(setID)
	if err != nil {
		e.log.Warn("control logic: config snapshot: unknown set", "set", setID, "err", err)
		return
	}
	ctx, cancel := context.WithTimeout(e.root, 5*time.Second)
	defer cancel()
	read := func(ds, signal string) (any, error) {
		if ds == "local" {
			return cg.getLocal(signal), nil
		}
		v, err := e.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: signal})
		if err != nil {
			return nil, err
		}
		return v.Data, nil
	}
	snap := confmgr.Snapshot(set, read)
	if name == "" {
		name = set.Name + " snapshot"
	}
	inst := confmgr.ConfigInstance{Name: name, SetID: setID, Values: snap.Values}
	ev := audit.Event{
		Actor:     cg.name,
		ActorType: audit.ActorSystem,
		Action:    "config.instance.snapshot",
		Detail:    "control logic config snapshot: set=" + setID + " name=" + name,
		Outcome:   audit.OutcomeOK,
	}
	out, err := e.cfg.CreateInstance(inst, "")
	if err != nil {
		e.log.Warn("control logic: config snapshot failed", "set", setID, "err", err)
		ev.Outcome = audit.OutcomeError
		ev.Error = err.Error()
	} else {
		ev.Detail += " -> " + out.ID + " captured=" + strconv.Itoa(snap.Captured) + " failed=" + strconv.Itoa(snap.Failed)
	}
	e.audit.Record(ev)
}

// coerceParamValue converts a numeric flow value to the Go type a config
// parameter expects, so the resulting instance validates against its set.
func coerceParamValue(p confmgr.Parameter, val float64) any {
	switch p.Type {
	case confmgr.TypeInt:
		return int64(val)
	case confmgr.TypeBool:
		return val != 0
	case confmgr.TypeString, confmgr.TypeEnum:
		return strconv.FormatFloat(val, 'g', -1, 64)
	default:
		return val
	}
}

// emitDialog delivers an action.dialog node's request to the installed Notifier
// (the WebSocket dialog hub). It is lock-free so it never deadlocks against a
// concurrent Reload that is waiting for this flow goroutine to finish.
func (e *Engine) emitDialog(n Node) {
	box, _ := e.notifier.Load().(notifierBox)
	if box.n == nil {
		return
	}
	kind := strings.TrimSpace(n.param("kind"))
	if kind != "error" && kind != "input" {
		kind = "info"
	}
	box.n.Notify(Dialog{
		ID:      strconv.FormatUint(atomic.AddUint64(&dialogSeq, 1), 10),
		Kind:    kind,
		Title:   n.param("title"),
		Message: n.param("message"),
		Target:  strings.TrimSpace(n.param("target")),
		Users:   splitCSV(n.param("users")),
		Groups:  splitCSV(n.param("groups")),
	})
}

// ── compiled graph ─────────────────────────────────────────────────────────────

type wireOut struct {
	to   string
	port string
}

// compiledGraph holds the runtime state for one enabled graph.
type compiledGraph struct {
	engine *Engine
	genCtx context.Context
	wg     *sync.WaitGroup

	// dryRun suppresses real side effects (data-source writes, config mutations,
	// dialogs) — used by the debug "simulate" sandbox. Local-variable writes stay
	// live so the flow still computes correctly. alwaysDebug forces emitDebug to
	// fire regardless of debugWatch (the sandbox is its own subscriber).
	dryRun      bool
	alwaysDebug bool

	id   string
	name string
	byId map[string]Node
	out  map[string][]wireOut
	inc  map[string][]string // incoming source ids per node (for gates)
	refs map[string]RefLite  // unique signals to subscribe (excl. sys/local)

	watchers map[string][]string // signal key → trigger node ids
	luaNodes map[string]*luaRuntime

	// fireCh receives node ids of triggers the debug UI wants to fire manually.
	// Drained by a generation goroutine (started in startTriggers) so the wg.Add
	// in activate always happens on a tracked goroutine.
	fireCh chan string

	stateMu    sync.Mutex
	levelState map[string]bool    // current truth of level triggers (threshold/alarm)
	prevBool   map[string]bool    // edge detection for threshold/alarm
	prevVal    map[string]float64 // last value for change triggers
	hasVal     map[string]bool
	lastFire   map[string]int64 // ns wall clock each trigger last fired
	locals     map[string]float64
}

func compile(g Graph) *compiledGraph {
	cg := &compiledGraph{
		id:         g.ID,
		name:       g.Name,
		byId:       map[string]Node{},
		out:        map[string][]wireOut{},
		inc:        map[string][]string{},
		refs:       map[string]RefLite{},
		watchers:   map[string][]string{},
		luaNodes:   map[string]*luaRuntime{},
		levelState: map[string]bool{},
		prevBool:   map[string]bool{},
		prevVal:    map[string]float64{},
		hasVal:     map[string]bool{},
		lastFire:   map[string]int64{},
		locals:     map[string]float64{},
		fireCh:     make(chan string, 16),
	}
	for _, n := range g.Nodes {
		cg.byId[n.ID] = n
	}
	for _, w := range g.Wires {
		port := w.FromPort
		if port == "" {
			port = "out"
		}
		cg.out[w.From] = append(cg.out[w.From], wireOut{to: w.To, port: port})
		cg.inc[w.To] = append(cg.inc[w.To], w.From)
	}

	want := func(ds, name string) {
		if ds == "sys" || ds == "local" {
			return
		}
		if name == "" {
			return
		}
		cg.refs[refKey(ds, name)] = RefLite{DS: ds, Name: name}
	}
	wantExpr := func(expr string) {
		for _, r := range CollectRefs(expr) {
			want(r.DS, r.Name)
		}
	}

	for _, n := range g.Nodes {
		switch n.Kind {
		case "trigger.threshold", "trigger.change", "trigger.alarm":
			if ds, name, ok := parseRef(n.param("signal")); ok {
				want(ds, name)
				key := refKey(ds, name)
				cg.watchers[key] = append(cg.watchers[key], n.ID)
			}
		case "flow.if":
			wantExpr(n.param("cond"))
		case "flow.loop":
			if n.param("mode") == "while" {
				wantExpr(n.param("cond"))
			}
		case "action.write", "action.log":
			wantExpr(n.param("expr"))
		case "action.lua":
			cg.luaNodes[n.ID] = newLuaRuntime(n.param("script"))
			for _, r := range luaGetRefs(n.param("script")) {
				want(r.DS, r.Name)
			}
		}
	}
	return cg
}

func (cg *compiledGraph) setLocal(name string, v float64) {
	cg.stateMu.Lock()
	cg.locals[name] = v
	cg.stateMu.Unlock()
}

func (cg *compiledGraph) getLocal(name string) float64 {
	cg.stateMu.Lock()
	defer cg.stateMu.Unlock()
	v, ok := cg.locals[name]
	if !ok {
		return 0
	}
	return v
}

// startTriggers launches timer and cron trigger goroutines for the generation.
func (cg *compiledGraph) startTriggers() {
	// Manual-fire listener: drain fireCh on a tracked goroutine so activate's
	// wg.Add never races the generation teardown's wg.Wait. Only fires nodes that
	// are actual triggers in this graph.
	cg.wg.Add(1)
	go func() {
		defer cg.wg.Done()
		for {
			select {
			case id := <-cg.fireCh:
				if n, ok := cg.byId[id]; ok && strings.HasPrefix(n.Kind, "trigger.") {
					cg.activate(id)
				}
			case <-cg.genCtx.Done():
				return
			}
		}
	}()

	hasCron := false
	for _, n := range cg.byId {
		switch n.Kind {
		case "trigger.timer":
			node := n
			cg.wg.Add(1)
			go func() {
				defer cg.wg.Done()
				d := intervalOf(node)
				t := time.NewTicker(d)
				defer t.Stop()
				for {
					select {
					case <-t.C:
						cg.activate(node.ID)
					case <-cg.genCtx.Done():
						return
					}
				}
			}()
		case "trigger.cron":
			hasCron = true
		}
	}
	if hasCron {
		cg.startCron()
	}
}

func (cg *compiledGraph) startCron() {
	type cronNode struct {
		id    string
		sched *Schedule
	}
	var crons []cronNode
	for _, n := range cg.byId {
		if n.Kind != "trigger.cron" {
			continue
		}
		sched, err := ParseSchedule(n.param("spec"))
		if err != nil {
			cg.engine.log.Warn("control logic: bad cron spec", "graph", cg.name, "spec", n.param("spec"), "err", err)
			continue
		}
		crons = append(crons, cronNode{id: n.ID, sched: sched})
	}
	if len(crons) == 0 {
		return
	}
	cg.wg.Add(1)
	go func() {
		defer cg.wg.Done()
		t := time.NewTicker(time.Second)
		defer t.Stop()
		lastMinute := -1
		for {
			select {
			case now := <-t.C:
				minute := now.Hour()*60 + now.Minute()
				if minute == lastMinute {
					continue // fire at most once per minute
				}
				lastMinute = minute
				for _, c := range crons {
					if c.sched.Match(now) {
						cg.activate(c.id)
					}
				}
			case <-cg.genCtx.Done():
				return
			}
		}
	}()
}

func intervalOf(n Node) time.Duration {
	ms, err := strconv.Atoi(strings.TrimSpace(n.param("interval")))
	if err != nil || ms < 50 {
		ms = 1000
	}
	return time.Duration(ms) * time.Millisecond
}

// ── trigger evaluation ─────────────────────────────────────────────────────────

// onSignal drives threshold/alarm (rising edge) and change triggers when a
// watched signal updates.
func (cg *compiledGraph) onSignal(key string, value float64) {
	for _, id := range cg.watchers[key] {
		node, ok := cg.byId[id]
		if !ok {
			continue
		}
		switch node.Kind {
		case "trigger.threshold":
			cur := testThreshold(value, node.param("op"), parseFloat(node.param("value")))
			cg.stateMu.Lock()
			cg.levelState[id] = cur
			prev := cg.prevBool[id]
			cg.prevBool[id] = cur
			cg.stateMu.Unlock()
			if cur && !prev {
				cg.activate(id)
			}
		case "trigger.alarm":
			lo := parseFloat(node.param("min"))
			hi := parseFloat(node.param("max"))
			cur := !math.IsNaN(value) && (value < lo || value > hi)
			cg.stateMu.Lock()
			cg.levelState[id] = cur
			prev := cg.prevBool[id]
			cg.prevBool[id] = cur
			cg.stateMu.Unlock()
			if cur && !prev {
				cg.activate(id)
			}
		case "trigger.change":
			cg.stateMu.Lock()
			had := cg.hasVal[id]
			prev := cg.prevVal[id]
			cg.prevVal[id] = value
			cg.hasVal[id] = true
			cg.stateMu.Unlock()
			if had && value != prev {
				cg.activate(id)
			}
		}
	}
}

func testThreshold(val float64, op string, cmp float64) bool {
	if math.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
	default: // ">"
		return val > cmp
	}
}

func parseFloat(s string) float64 {
	f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
	if err != nil {
		return 0
	}
	return f
}

// ── execution ──────────────────────────────────────────────────────────────────

type runCtx struct {
	fired   string
	steps   int
	resolve Resolver
}

// activate spawns a flow run for a trigger on its own goroutine so that
// action.delay does not block signal dispatch or other flows.
func (cg *compiledGraph) activate(triggerID string) {
	now := time.Now().UnixNano()
	cg.stateMu.Lock()
	last, had := cg.lastFire[triggerID]
	cg.lastFire[triggerID] = now
	cg.stateMu.Unlock()
	dt := 0.0
	if had {
		dt = float64(now-last) / 1e9
	}

	resolve := func(ds, name string) float64 {
		switch ds {
		case "sys":
			if name == "dt" {
				return dt
			}
			return cg.engine.liveGet("sys", name)
		case "local":
			return cg.getLocal(name)
		default:
			return cg.engine.liveGet(ds, name)
		}
	}

	cg.emitDebug(triggerID, 0, false)

	cg.wg.Add(1)
	go func() {
		defer cg.wg.Done()
		ctx := &runCtx{fired: triggerID, resolve: resolve}
		cg.follow(triggerID, "out", ctx)
	}()
}

func (cg *compiledGraph) follow(fromID, port string, ctx *runCtx) {
	for _, w := range cg.out[fromID] {
		if w.port == port {
			cg.run(w.to, ctx)
		}
	}
}

func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
	if ctx.steps > maxSteps {
		return
	}
	ctx.steps++
	node, ok := cg.byId[nodeID]
	if !ok {
		return
	}

	select {
	case <-cg.genCtx.Done():
		return
	default:
	}

	cg.emitDebug(node.ID, 0, false)

	switch node.Kind {
	case "gate.and":
		if cg.gateSatisfied(node.ID, ctx.fired) {
			cg.follow(node.ID, "out", ctx)
		}

	case "flow.if":
		branch := "else"
		pass := EvalBool(node.param("cond"), ctx.resolve)
		if pass {
			branch = "then"
		}
		v := 0.0
		if pass {
			v = 1
		}
		cg.emitDebug(node.ID, v, true)
		cg.follow(node.ID, branch, ctx)

	case "flow.loop":
		if node.param("mode") == "while" {
			for i := 0; i < maxLoop && ctx.steps <= maxSteps && EvalBool(node.param("cond"), ctx.resolve); i++ {
				cg.follow(node.ID, "body", ctx)
			}
		} else {
			n := int(EvalExpr(node.param("count"), ctx.resolve))
			if n < 0 {
				n = 0
			}
			if n > maxLoop {
				n = maxLoop
			}
			for i := 0; i < n && ctx.steps <= maxSteps; i++ {
				cg.follow(node.ID, "body", ctx)
			}
		}
		cg.follow(node.ID, "done", ctx)

	case "action.write":
		val := EvalExpr(node.param("expr"), ctx.resolve)
		cg.emitDebug(node.ID, val, true)
		cg.engine.write(cg, node.param("target"), val)
		cg.follow(node.ID, "out", ctx)

	case "action.config.apply":
		if !cg.dryRun {
			cg.engine.applyConfig(cg, strings.TrimSpace(node.param("instance")))
		}
		cg.follow(node.ID, "out", ctx)

	case "action.config.read":
		v, ok := cg.engine.readConfigParam(strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")))
		if ok {
			cg.engine.write(cg, node.param("target"), v)
		}
		cg.follow(node.ID, "out", ctx)

	case "action.config.write":
		val := EvalExpr(node.param("expr"), ctx.resolve)
		cg.emitDebug(node.ID, val, true)
		if !cg.dryRun {
			cg.engine.writeConfigParam(cg, strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")), val)
		}
		cg.follow(node.ID, "out", ctx)

	case "action.config.create":
		if !cg.dryRun {
			cg.engine.createConfigInstance(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")), strings.TrimSpace(node.param("from")))
		}
		cg.follow(node.ID, "out", ctx)

	case "action.config.snapshot":
		if !cg.dryRun {
			cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")))
		}
		cg.follow(node.ID, "out", ctx)

	case "action.delay":
		ms := 0
		if v, err := strconv.Atoi(strings.TrimSpace(node.param("ms"))); err == nil && v > 0 {
			ms = v
		}
		if ms > 0 {
			t := time.NewTimer(time.Duration(ms) * time.Millisecond)
			select {
			case <-t.C:
			case <-cg.genCtx.Done():
				t.Stop()
				return
			}
		}
		cg.follow(node.ID, "out", ctx)

	case "action.log":
		val := EvalExpr(node.param("expr"), ctx.resolve)
		cg.emitDebug(node.ID, val, true)
		label := strings.TrimSpace(node.param("label"))
		cg.engine.log.Info("control logic log", "graph", cg.name, "label", label, "value", val)
		cg.follow(node.ID, "out", ctx)

	case "action.lua":
		cg.runLua(node.ID, ctx)
		cg.follow(node.ID, "out", ctx)

	case "action.dialog":
		if !cg.dryRun {
			cg.engine.emitDialog(node)
		}
		cg.follow(node.ID, "out", ctx)

	default:
		cg.follow(node.ID, "out", ctx)
	}
}

// gateSatisfied: every incoming trigger must currently be satisfied. The firing
// trigger counts as satisfied; level triggers (threshold/alarm) use their truth.
func (cg *compiledGraph) gateSatisfied(gateID, fired string) bool {
	inputs := cg.inc[gateID]
	if len(inputs) == 0 {
		return false
	}
	cg.stateMu.Lock()
	defer cg.stateMu.Unlock()
	for _, src := range inputs {
		if src == fired || cg.levelState[src] {
			continue
		}
		return false
	}
	return true
}

// luaGetRefs scans a Lua script for get("ds:name") / get('ds:name') literals so
// the engine can subscribe to the signals the script reads.
var luaGetRe = regexp.MustCompile(`get\s*\(\s*["']([^"']+)["']`)

func luaGetRefs(script string) []RefLite {
	var out []RefLite
	for _, m := range luaGetRe.FindAllStringSubmatch(script, -1) {
		if ds, name, ok := parseRef(m[1]); ok {
			out = append(out, RefLite{DS: ds, Name: name})
		}
	}
	return out
}

func (cg *compiledGraph) runLua(nodeID string, ctx *runCtx) {
	lr := cg.luaNodes[nodeID]
	if lr == nil {
		return
	}
	lr.run(ctx.resolve, func(target string, val float64) {
		cg.engine.write(cg, target, val)
	}, func(msg string) {
		cg.engine.log.Info("control logic lua", "graph", cg.name, "msg", msg)
	})
}
