using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using System.Globalization;
using System.Text.Json;

namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;

/// <summary>
/// Script Actor — coordinator actor, child of Instance Actor.
/// Holds compiled script delegate, manages trigger configuration, and launches script
/// runs per invocation. Does not block on run completion.
///
/// <para>WP3.1: runs are launched directly via <see cref="ScriptRunLauncher"/> rather than
/// through a short-lived <c>ScriptExecutionActor</c> child (which had no <c>Receive</c>
/// handler, no <c>PostStop</c>, and whose <c>IActorRef</c> was never a message target — pure
/// per-run actor-cell overhead). Concurrent runs are bounded by
/// <see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/>; over the cap the NEWEST
/// trigger is shed. Trigger-expression evaluation no longer runs on the blocking script pool
/// at all — see <see cref="TriggerEvalGate"/>.</para>
///
/// Trigger types:
///   - Interval: uses Akka timers to fire periodically
///   - ValueChange: receives attribute change notifications from Instance Actor
///   - Conditional: evaluates a threshold comparison on attribute change
///   - Expression: evaluates a compiled boolean expression on attribute change
/// Conditional and Expression triggers carry a <see cref="TriggerMode"/>:
/// OnTrue fires as the condition becomes true; WhileTrue additionally re-fires
/// on a timer (cadence = MinTimeBetweenRuns) while the condition stays true.
///
/// Supervision strategy: Resume on exception (coordinator preserves state).
/// </summary>
public class ScriptActor : ReceiveActor, IWithTimers
{
    private readonly string _scriptName;
    private readonly string _instanceName;
    private readonly IActorRef _instanceActor;
    private readonly SharedScriptLibrary _sharedScriptLibrary;
    private readonly SiteRuntimeOptions _options;
    private readonly ILogger _logger;
    private readonly ISiteHealthCollector? _healthCollector;
    private readonly IServiceProvider? _serviceProvider;

    /// <summary>
    /// Script-execution scheduler seam (#18): the process-wide
    /// <see cref="ScriptExecutionScheduler"/> when null, or an injected instance so
    /// this actor's launched script bodies run on a caller-owned pool instead of the
    /// shared one. Resolved lazily at each use so the null (host) path stays
    /// byte-for-byte identical to the previous static call.
    ///
    /// <para>WP3.1: trigger-expression evaluation no longer uses this scheduler — see
    /// <see cref="_evalGate"/>.</para>
    /// </summary>
    private readonly ScriptExecutionScheduler? _scheduler;

    /// <summary>
    /// WP3.1 (finding #4): the concurrency gate for trigger-expression evaluation, or null
    /// for the process-wide <see cref="TriggerEvalGate.Shared"/>. Evaluations run as plain
    /// async work on the shared .NET thread pool behind this gate, NOT on
    /// <see cref="_scheduler"/> — that is what stops an Expression trigger from queueing
    /// behind blocking script bodies.
    /// </summary>
    private readonly TriggerEvalGate? _evalGate;

    /// <summary>
    /// WP3.1: runs launched but not yet completed (queued or executing) for this script.
    /// Incremented at launch, decremented on <see cref="ScriptExecutionCompleted"/>, which
    /// every terminal path emits — including the launch-path catch, so the counter cannot
    /// leak. Touched only on the actor thread.
    /// </summary>
    private int _runsInFlight;

    /// <summary>
    /// WP3.1: when the last shed Warning site event was emitted for this script. Sheds are
    /// ALWAYS counted on the health collector; the site event is rate-limited to one per
    /// script per minute so a hot trigger against a saturated cap cannot flood
    /// <c>site_events</c>.
    /// </summary>
    private DateTimeOffset _lastShedEventUtc = DateTimeOffset.MinValue;

    /// <summary>Rate limit for the shed site event (the counter still counts every shed).</summary>
    private static readonly TimeSpan ShedEventInterval = TimeSpan.FromMinutes(1);

    private Script<object?>? _compiledScript;
    private ScriptTriggerConfig? _triggerConfig;
    private TimeSpan? _minTimeBetweenRuns;

    /// <summary>
    /// The per-script execution timeout in seconds, or null to use the
    /// global default. Threaded down to each launched run via
    /// <see cref="ScriptRunLauncher"/>, which applies <c>perScript ?? global</c>
    /// (and treats ≤ 0 as "use global").
    /// </summary>
    private readonly int? _executionTimeoutSeconds;
    private DateTimeOffset _lastExecutionTime = DateTimeOffset.MinValue;
    private int _executionCounter;
    private readonly Commons.Types.Scripts.ScriptScope _scope;

    // Expression trigger state: compiled expression, edge-tracking, and the
    // attribute snapshot the expression evaluates against.
    private readonly Script<object?>? _compiledTriggerExpression;
    private bool _lastExpressionResult;
    private readonly Dictionary<string, object?> _attributeSnapshot = new();
    // Coalescing guard for off-dispatcher expression evaluation (P1): at most one
    // evaluation runs on the script scheduler at a time; a change arriving while
    // one is in flight sets _evalPending so exactly one more runs afterwards
    // against the latest snapshot. Both flags are touched only on the actor thread.
    private bool _evalInFlight;
    private bool _evalPending;

    // WhileTrue trigger state: the most recent truth value of a Conditional
    // trigger's comparison, used to detect false->true / true->false edges.
    // (Expression triggers reuse _lastExpressionResult for the same purpose.)
    private bool _conditionState;

    /// <summary>Timer key for the WhileTrue re-fire timer (cadence = MinTimeBetweenRuns).</summary>
    private const string WhileTrueTimerKey = "whiletrue-trigger";

    /// <summary>
    /// The exact dictionary instance this actor was seeded from
    /// at construction. The Instance Actor must pass a private snapshot here, not
    /// its live <c>_attributes</c> field — sharing the live dictionary lets this
    /// constructor enumerate it while the Instance Actor mutates it on another
    /// thread. Exposed for regression coverage of that isolation contract.
    /// </summary>
    internal IReadOnlyDictionary<string, object?>? SeedAttributesReference { get; }

    /// <summary>Gets or sets the Akka timer scheduler used to schedule interval and WhileTrue triggers.</summary>
    public ITimerScheduler Timers { get; set; } = null!;

    /// <summary>
    /// Initializes the ScriptActor with its compiled script, trigger configuration, and supporting services.
    /// </summary>
    /// <param name="scriptName">Name of the script this actor manages.</param>
    /// <param name="instanceName">Unique name of the owning instance.</param>
    /// <param name="instanceActor">Reference to the parent Instance Actor.</param>
    /// <param name="compiledScript">Pre-compiled Roslyn script delegate, or null when compilation failed.</param>
    /// <param name="scriptConfig">Resolved script metadata including trigger type and configuration.</param>
    /// <param name="sharedScriptLibrary">Library of compiled shared scripts available for inline execution.</param>
    /// <param name="options">Site runtime configuration options.</param>
    /// <param name="logger">Logger for diagnostics.</param>
    /// <param name="compiledTriggerExpression">Pre-compiled boolean trigger expression, or null when not an expression trigger.</param>
    /// <param name="initialAttributes">Initial attribute snapshot used to seed expression trigger evaluation state.</param>
    /// <param name="healthCollector">Optional health metrics collector.</param>
    /// <param name="serviceProvider">Optional DI service provider for script execution context services.</param>
    /// <param name="scheduler">Optional script-execution scheduler override (#18); null uses the process-wide shared scheduler.</param>
    /// <param name="evalGate">Optional trigger-expression concurrency gate override (WP3.1); null uses the process-wide shared gate.</param>
    public ScriptActor(
        string scriptName,
        string instanceName,
        IActorRef instanceActor,
        Script<object?>? compiledScript,
        ResolvedScript scriptConfig,
        SharedScriptLibrary sharedScriptLibrary,
        SiteRuntimeOptions options,
        ILogger logger,
        Script<object?>? compiledTriggerExpression = null,
        IReadOnlyDictionary<string, object?>? initialAttributes = null,
        ISiteHealthCollector? healthCollector = null,
        IServiceProvider? serviceProvider = null,
        ScriptExecutionScheduler? scheduler = null,
        TriggerEvalGate? evalGate = null)
    {
        _scriptName = scriptName;
        _instanceName = instanceName;
        _instanceActor = instanceActor;
        _compiledScript = compiledScript;
        _sharedScriptLibrary = sharedScriptLibrary;
        _options = options;
        _logger = logger;
        _healthCollector = healthCollector;
        _serviceProvider = serviceProvider;
        _scheduler = scheduler;
        _evalGate = evalGate;
        _minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
        _executionTimeoutSeconds = scriptConfig.ExecutionTimeoutSeconds;
        _scope = scriptConfig.Scope;
        _compiledTriggerExpression = compiledTriggerExpression;

        // Seed the trigger-expression attribute snapshot from the instance's
        // initial attribute set so static attributes (which never re-emit an
        // AttributeValueChanged after deploy) evaluate correctly at startup.
        SeedAttributesReference = initialAttributes;
        if (initialAttributes != null)
        {
            foreach (var kvp in initialAttributes)
                _attributeSnapshot[kvp.Key] = kvp.Value;
        }

        // Parse trigger configuration
        _triggerConfig = ParseTriggerConfig(scriptConfig.TriggerType, scriptConfig.TriggerConfiguration);

        // Handle script call requests (Ask pattern from Instance Actor or ScriptRuntimeContext)
        Receive<ScriptCallRequest>(HandleScriptCallRequest);

        // Handle attribute value changes for value-change and conditional triggers
        Receive<AttributeValueChanged>(HandleAttributeValueChanged);

        // Handle interval tick
        Receive<IntervalTick>(_ => TrySpawnExecution(null));

        // Handle WhileTrue re-fire tick
        Receive<WhileTrueTick>(_ => FireWhileTrueTick());

        // Handle execution completion (for logging/metrics)
        Receive<ScriptExecutionCompleted>(HandleExecutionCompleted);

        // Handle the off-dispatcher trigger-expression evaluation result (P1).
        Receive<ExpressionEvalResult>(HandleExpressionEvalResult);

        // Handle a faulted off-dispatcher evaluation task (N2) — clears in-flight
        // instead of stranding the trigger with an unhandled Status.Failure.
        Receive<ExpressionEvalFailed>(HandleExpressionEvalFailed);
    }

    /// <inheritdoc />
    protected override void PreStart()
    {
        base.PreStart();

        // Set up interval trigger if configured
        if (_triggerConfig is IntervalTriggerConfig interval)
        {
            Timers.StartPeriodicTimer(
                "interval-trigger",
                IntervalTick.Instance,
                interval.Interval,
                interval.Interval);

            _logger.LogDebug(
                "ScriptActor {Script} on {Instance}: interval trigger set to {Interval}",
                _scriptName, _instanceName, interval.Interval);
        }

        _logger.LogInformation(
            "ScriptActor {Script} started on instance {Instance}",
            _scriptName, _instanceName);
    }

    // WP3.1: the Stop-on-failure SupervisorStrategy override is gone with the per-run
    // ScriptExecutionActor child it supervised. This actor has no children left; a
    // launch-path throw is caught in SpawnExecution (which replies to the Ask caller and
    // releases the in-flight slot) so it can never escalate to InstanceActor, which
    // continues to supervise this actor with Resume exactly as before.

    /// <summary>
    /// Handles CallScript ask from ScriptRuntimeContext or Instance Actor.
    /// Launches a run and captures the sender for the eventual reply.
    /// </summary>
    private void HandleScriptCallRequest(ScriptCallRequest request)
    {
        if (_compiledScript == null)
        {
            Sender.Tell(new ScriptCallResult(
                request.CorrelationId,
                false,
                null,
                $"Script '{_scriptName}' is not compiled."));
            return;
        }

        // (ParentExecutionId): carry any inbound-routed ParentExecutionId
        // through to the launched run so the routed script's
        // ScriptRuntimeContext can record its spawner. Null for normal
        // (tag-change / timer) runs and nested Script.Call invocations.
        SpawnExecution(
            request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId,
            request.ParentExecutionId);
    }

    /// <summary>
    /// Handles attribute value changes — triggers script if configured for
    /// value-change, conditional, or expression. The attribute snapshot is
    /// updated for every change before any trigger logic runs.
    /// </summary>
    private void HandleAttributeValueChanged(AttributeValueChanged changed)
    {
        // Keep the snapshot current for every change, regardless of trigger type.
        _attributeSnapshot[changed.AttributeName] = changed.Value;

        if (_triggerConfig is ValueChangeTriggerConfig valueTrigger)
        {
            if (valueTrigger.AttributeName == changed.AttributeName)
            {
                TrySpawnExecution(null);
            }
        }
        else if (_triggerConfig is ConditionalTriggerConfig conditional)
        {
            if (conditional.AttributeName == changed.AttributeName)
            {
                var conditionMet = EvaluateCondition(conditional, changed.Value);
                if (conditional.Mode == TriggerMode.WhileTrue)
                {
                    // Edge-detect against the prior truth value; the timer does
                    // the repeated firing while the condition stays true.
                    HandleWhileTrueTransition(conditionMet, _conditionState);
                    _conditionState = conditionMet;
                }
                else if (conditionMet)
                {
                    // OnTrue: fire on each matching change (existing behavior).
                    TrySpawnExecution(null);
                }
            }
        }
        else if (_triggerConfig is ExpressionTriggerConfig)
        {
            StartExpressionEvaluation();
        }
    }

    /// <summary>
    /// Starts an off-dispatcher evaluation of the compiled trigger expression (P1).
    /// The expression previously ran synchronously on the actor's dispatcher thread
    /// via <c>RunAsync(...).GetAwaiter().GetResult()</c>, blocking the dispatcher on
    /// every attribute change. It runs off-thread against a point-in-time snapshot;
    /// the boolean result is piped back to this actor as an
    /// <see cref="ExpressionEvalResult"/> so all edge state is applied on the actor
    /// thread. Bursts coalesce: at most one evaluation is in flight, at most one
    /// pending, so a storm of changes collapses to the latest snapshot without
    /// unbounded task fan-out.
    ///
    /// <para>WP3.1 (finding #4) changed WHERE it runs and WHEN its clock starts. It used
    /// to run on the dedicated <see cref="ScriptExecutionScheduler"/> — the same bounded
    /// pool as blocking script bodies — so N blocked scripts stalled every Expression
    /// trigger on the node indefinitely. It now runs as plain async work on the shared
    /// .NET thread pool behind <see cref="TriggerEvalGate"/>: trigger expressions are
    /// non-blocking by construction, so they belong there. And the deadline
    /// <see cref="CancellationTokenSource"/> is armed HERE, on the actor thread, before
    /// the work is queued — so gate-wait time burns the same budget and a saturated gate
    /// yields a timely false instead of an unbounded stall.</para>
    /// </summary>
    private void StartExpressionEvaluation()
    {
        if (_compiledTriggerExpression == null || _triggerConfig is not ExpressionTriggerConfig) return;
        if (_evalInFlight) { _evalPending = true; return; } // coalesce bursts: one in flight, one pending
        _evalInFlight = true;

        var snapshot = new Dictionary<string, object?>(_attributeSnapshot); // point-in-time copy, actor thread
        var expression = _compiledTriggerExpression;
        var self = Self;
        var gate = _evalGate ?? TriggerEvalGate.Shared(_options);
        // Clock starts AT ENQUEUE, not at dequeue.
        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_options.TriggerEvalTimeoutSeconds));

        Task.Run(async () =>
        {
            try
            {
                await gate.WaitAsync(cts.Token).ConfigureAwait(false);
                try
                {
                    // The CancellationToken covers cooperative/async cases; a pathological
                    // CPU-bound expression is not fully interruptible — acceptable because
                    // trigger expressions are authored by trusted Design-role users and
                    // compile-checked pre-deploy.
                    var state = await expression.RunAsync(
                        new TriggerExpressionGlobals(snapshot), cancellationToken: cts.Token);
                    return state.ReturnValue is bool b && b;
                }
                finally
                {
                    gate.Release();
                }
            }
            catch (Exception ex)
            {
                // OperationCanceledException (timeout, INCLUDING a timeout that fired while
                // still waiting on the gate) falls through here too and is treated as false.
                // LogExpressionError touches only thread-safe members (_healthCollector
                // Interlocked, _logger, DI-resolved singleton logger) so it is safe off the
                // actor thread.
                LogExpressionError(ex);
                return false;
            }
            finally
            {
                cts.Dispose();
            }
        }).PipeTo(self,
            success: r => new ExpressionEvalResult(r),
            failure: ex => new ExpressionEvalFailed(ex));
    }

    /// <summary>
    /// Applies an off-dispatcher trigger-expression result on the actor thread:
    /// runs the OnTrue / WhileTrue edge logic against <see cref="_lastExpressionResult"/>,
    /// clears the in-flight flag, and drains a coalesced pending evaluation (against
    /// the now-current snapshot) if one was requested while this one ran.
    /// </summary>
    private void HandleExpressionEvalResult(ExpressionEvalResult msg)
    {
        _evalInFlight = false;
        if (_triggerConfig is ExpressionTriggerConfig exprConfig)
        {
            if (exprConfig.Mode == TriggerMode.WhileTrue)
                HandleWhileTrueTransition(msg.Result, _lastExpressionResult);
            else if (msg.Result && !_lastExpressionResult)
                TrySpawnExecution(null);
            _lastExpressionResult = msg.Result;
        }
        if (_evalPending) { _evalPending = false; StartExpressionEvaluation(); }
    }

    /// <summary>
    /// Applies a faulted off-dispatcher evaluation task (N2): logs the fault via the
    /// existing error path (which also increments the health counter), then reuses the
    /// false-result path — clearing in-flight, applying the false edge, and draining any
    /// pending evaluation — so a transient scheduler fault cannot permanently park the trigger.
    /// </summary>
    private void HandleExpressionEvalFailed(ExpressionEvalFailed msg)
    {
        LogExpressionError(msg.Cause);
        HandleExpressionEvalResult(new ExpressionEvalResult(false));
    }

    /// <summary>
    /// Applies a WhileTrue trigger's condition-state transition: on the
    /// false→true edge, fire once and start the re-fire timer; on the
    /// true→false edge, stop the timer. While the state is unchanged, the
    /// already-running timer continues to drive re-firing.
    /// </summary>
    private void HandleWhileTrueTransition(bool nowTrue, bool wasTrue)
    {
        if (nowTrue && !wasTrue)
        {
            TrySpawnExecution(null);
            StartWhileTrueTimer();
        }
        else if (!nowTrue && wasTrue)
        {
            StopWhileTrueTimer();
        }
    }

    /// <summary>
    /// Starts the periodic WhileTrue re-fire timer. The cadence is the script's
    /// <c>MinTimeBetweenRuns</c>; with none configured the trigger cannot
    /// re-fire, so it degrades to the single edge fire and logs a warning.
    /// </summary>
    private void StartWhileTrueTimer()
    {
        if (_compiledScript == null) return;

        if (_minTimeBetweenRuns is not { } interval)
        {
            _logger.LogWarning(
                "ScriptActor {Script} on {Instance}: WhileTrue trigger has no MinTimeBetweenRuns — " +
                "firing once on the edge only, no re-fire timer.",
                _scriptName, _instanceName);
            return;
        }

        Timers.StartPeriodicTimer(WhileTrueTimerKey, WhileTrueTick.Instance, interval, interval);
    }

    /// <summary>Cancels the WhileTrue re-fire timer (a no-op if it is not running).</summary>
    private void StopWhileTrueTimer() => Timers.Cancel(WhileTrueTimerKey);

    /// <summary>
    /// Fires the script for a WhileTrue re-fire tick. The timer interval is
    /// itself the cadence, so this spawns directly — bypassing the
    /// MinTimeBetweenRuns skip-check that gates change-driven spawns (which
    /// could otherwise drop a tick to sub-millisecond timing jitter).
    /// </summary>
    private void FireWhileTrueTick()
    {
        if (_compiledScript == null) return;

        _lastExecutionTime = DateTimeOffset.UtcNow;
        SpawnExecution(null, 0, ActorRefs.NoSender!, Guid.NewGuid().ToString());
    }

    /// <summary>
    /// Records a trigger-expression evaluation failure to the site event log,
    /// mirroring how a script run reports its own errors.
    /// </summary>
    private void LogExpressionError(Exception ex)
    {
        _healthCollector?.IncrementScriptError();
        var errorMsg = $"Trigger expression for script '{_scriptName}' on instance '{_instanceName}' failed: {ex.Message}";
        _logger.LogError(ex, "Trigger expression evaluation failed: {Script} on {Instance}", _scriptName, _instanceName);

        _ = _serviceProvider?.GetService<ISiteEventLogger>()?.LogEventAsync(
            "script", "Error", _instanceName, $"ScriptActor:{_scriptName}", errorMsg, ex.ToString());
    }

    /// <summary>
    /// Attempts to spawn a script execution, respecting MinTimeBetweenRuns.
    /// </summary>
    private void TrySpawnExecution(IReadOnlyDictionary<string, object?>? parameters)
    {
        if (_compiledScript == null) return;

        if (_minTimeBetweenRuns.HasValue)
        {
            var elapsed = DateTimeOffset.UtcNow - _lastExecutionTime;
            if (elapsed < _minTimeBetweenRuns.Value)
            {
                _logger.LogDebug(
                    "Script {Script} on {Instance}: skipping execution, min time between runs not elapsed ({Elapsed} < {Min})",
                    _scriptName, _instanceName, elapsed, _minTimeBetweenRuns.Value);
                return;
            }
        }

        _lastExecutionTime = DateTimeOffset.UtcNow;
        SpawnExecution(parameters, 0, ActorRefs.NoSender!, Guid.NewGuid().ToString());
    }

    /// <summary>
    /// Launches a run of this script. Multiple concurrent runs are allowed, up to
    /// <see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/>; beyond that the newest
    /// trigger is shed (see <see cref="ShedRun"/>).
    ///
    /// <para>WP3.1: the run is launched directly on the script-execution scheduler via
    /// <see cref="ScriptRunLauncher"/> — the actor's mailbox stays on the default dispatcher,
    /// but the script body runs on the bounded set of dedicated threads, so blocking script
    /// I/O is contained there and cannot starve the shared .NET thread pool. No per-run child
    /// actor is created.</para>
    ///
    /// <para>The cap governs NEW work only (<paramref name="callDepth"/> 0 — trigger-driven
    /// runs and depth-0 Ask calls). A nested <c>CallScript</c> is exempt: the calling run is
    /// itself still counted in <see cref="_runsInFlight"/> while it awaits its callee (the
    /// slot is released only by <see cref="ScriptExecutionCompleted"/>, sent after the body
    /// returns), and a script calling ITSELF routes back to this same actor — so counting the
    /// nested launch against the cap would refuse legitimate self-recursion at depth
    /// <see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/> with a misleading "shed",
    /// making <see cref="SiteRuntimeOptions.MaxScriptCallDepth"/> — the limit that actually
    /// owns this path, enforced in <c>ScriptRuntimeContext.CallScript</c> — unreachable.
    /// Nested depth is bounded by MaxScriptCallDepth instead, which is what the cap would
    /// otherwise be doing badly.</para>
    /// </summary>
    private void SpawnExecution(
        IReadOnlyDictionary<string, object?>? parameters,
        int callDepth,
        IActorRef replyTo,
        string correlationId,
        Guid? parentExecutionId = null)
    {
        if (callDepth == 0 && _runsInFlight >= _options.MaxConcurrentRunsPerScript)
        {
            ShedRun(replyTo, correlationId);
            return;
        }

        var runId = _executionCounter++;
        // Incremented BEFORE the launch so the launch-path catch below (which always emits a
        // ScriptExecutionCompleted) balances it on every path — the counter cannot leak.
        _runsInFlight++;

        try
        {
            ScriptRunLauncher.LaunchScript(
                _scriptName,
                _instanceName,
                _compiledScript!,
                parameters,
                callDepth,
                _instanceActor,
                _sharedScriptLibrary,
                _options,
                replyTo,
                correlationId,
                // Completion target: this actor. Identical delivery to the old
                // Context.Parent.Tell from the execution actor, and it doubles as the
                // in-flight release.
                Self,
                // Notification Outbox: the site communication actor Notify.Status queries
                // central through. Resolved here, on the actor thread, and handed to the
                // launcher (which has no ActorContext of its own).
                Context.System.ActorSelection("/user/site-communication"),
                _logger,
                _scope,
                runId,
                _healthCollector,
                _serviceProvider,
                // (ParentExecutionId): null for trigger-driven runs;
                // an inbound-API-routed call supplies the inbound request's id.
                parentExecutionId,
                // Per-script timeout override (null = use global).
                _executionTimeoutSeconds,
                // Scheduler seam (#18): thread this actor's scheduler override down so
                // launched script bodies share the same pool (null = process-wide shared).
                _scheduler);
        }
        catch (Exception ex)
        {
            // WP3.1 supervision parity: the only failure the removed per-run child could
            // surface was a constructor throw (e.g. queueing onto a disposed scheduler),
            // which the old OneForOneStrategy logged and stopped — leaving an Ask caller to
            // hang with no reply. Same log shape, same "coordinator unaffected" outcome, plus
            // a reply so the caller fails fast instead of waiting out its Ask timeout.
            var errorMsg = $"Script '{_scriptName}' on instance '{_instanceName}' could not be launched: {ex.Message}";
            _logger.LogWarning(ex,
                "Script execution launch for {Script} on {Instance} failed, stopping",
                _scriptName, _instanceName);

            if (!replyTo.IsNobody())
                replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg));

            Self.Tell(new ScriptExecutionCompleted(_scriptName, false, errorMsg));
        }
    }

    /// <summary>
    /// WP3.1 shed policy: refuses the incoming run because
    /// <see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/> runs are already in flight.
    /// The four already queued/running are kept — they are closest to their own deadlines and
    /// already charged against them — so nothing is ever reordered. A trigger-driven run is
    /// simply not launched; a depth-0 Ask (an inbound-API route, or a <c>CallScript</c> from
    /// an unrelated script's run) gets an explicit error so the caller fails fast rather than
    /// hanging to its Ask timeout. Nested (<c>callDepth &gt; 0</c>) launches never reach here —
    /// see <see cref="SpawnExecution"/>.
    /// </summary>
    private void ShedRun(IActorRef replyTo, string correlationId)
    {
        _healthCollector?.IncrementScriptRunShed();

        var message = $"Script '{_scriptName}' on instance '{_instanceName}': run shed — " +
                      $"{_runsInFlight} runs already in flight (cap {_options.MaxConcurrentRunsPerScript}).";

        _logger.LogWarning("{Message}", message);

        // Rate-limited to one event per script per minute: the counter above still counts
        // every shed, but a hot trigger against a saturated cap must not flood site_events.
        var now = DateTimeOffset.UtcNow;
        if (now - _lastShedEventUtc >= ShedEventInterval)
        {
            _lastShedEventUtc = now;
            _ = _serviceProvider?.GetService<ISiteEventLogger>()?.LogEventAsync(
                "script", "Warning", _instanceName, $"ScriptActor:{_scriptName}", message);
        }

        if (!replyTo.IsNobody())
        {
            replyTo.Tell(new ScriptCallResult(
                correlationId, false, null,
                $"shed: {_runsInFlight} runs already in flight"));
        }
    }

    private void HandleExecutionCompleted(ScriptExecutionCompleted msg)
    {
        // WP3.1: release the in-flight slot. Every terminal path emits exactly one of these
        // (success / timeout / failure / launch failure), so the counter tracks reality.
        if (_runsInFlight > 0) _runsInFlight--;

        _logger.LogDebug(
            "Script {Script} execution completed on {Instance}: success={Success}",
            _scriptName, _instanceName, msg.Success);
    }

    /// <summary>
    /// WP3.1: runs launched but not yet completed. Exposed for regression coverage of the
    /// shed cap — the counter is otherwise invisible from outside the actor.
    /// </summary>
    internal int RunsInFlight => _runsInFlight;

    // internal (not private) so the culture-invariance of the non-numeric fallback
    // can be unit-tested directly on the test thread — the live path evaluates on a
    // dispatcher thread whose CurrentCulture the test cannot deterministically set.
    /// <summary>
    /// Evaluates a conditional trigger's operator/threshold against an attribute value,
    /// converting the value to a culture-invariant double before comparing.
    /// </summary>
    /// <param name="config">The trigger configuration specifying the operator and threshold.</param>
    /// <param name="value">The attribute value to evaluate, or null.</param>
    /// <returns><see langword="true"/> if the value satisfies the configured condition; otherwise <see langword="false"/>.</returns>
    internal static bool EvaluateCondition(ConditionalTriggerConfig config, object? value)
    {
        if (value == null) return false;

        try
        {
            // Use InvariantCulture so a string attribute value like "1.5" parses
            // consistently regardless of the host locale. For
            // purely-numeric inputs the culture argument is a no-op, but it is
            // safe and future-proof for string-typed attribute values arriving
            // from scripts or the data connection layer.
            var numericValue = Convert.ToDouble(value, CultureInfo.InvariantCulture);
            return config.Operator switch
            {
                ">" => numericValue > config.Threshold,
                ">=" => numericValue >= config.Threshold,
                "<" => numericValue < config.Threshold,
                "<=" => numericValue <= config.Threshold,
                "==" => Math.Abs(numericValue - config.Threshold) < 0.0001,
                "!=" => Math.Abs(numericValue - config.Threshold) >= 0.0001,
                _ => false
            };
        }
        catch
        {
            // Render the threshold with InvariantCulture to match the invariant numeric
            // parse two lines up — otherwise a de-DE host renders 1.5 as "1,5" and the
            // string fallback fires spuriously on locale-formatted values (C6).
            return string.Equals(value.ToString(), config.Threshold.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal);
        }
    }

    private static ScriptTriggerConfig? ParseTriggerConfig(string? triggerType, string? triggerConfigJson)
    {
        if (string.IsNullOrEmpty(triggerType)) return null;

        return triggerType.ToLowerInvariant() switch
        {
            "interval" => ParseIntervalTrigger(triggerConfigJson),
            "valuechange" => ParseValueChangeTrigger(triggerConfigJson),
            "conditional" => ParseConditionalTrigger(triggerConfigJson),
            "expression" => ParseExpressionTrigger(triggerConfigJson),
            "call" => null, // No automatic trigger — invoked only via Instance.CallScript()
            _ => null
        };
    }

    private static ExpressionTriggerConfig? ParseExpressionTrigger(string? json)
    {
        var expr = TriggerExpressionGlobals.ExtractExpression(json);
        if (expr == null) return null;

        // ExtractExpression already proved the JSON parses; read the mode too.
        var mode = TriggerMode.OnTrue;
        try
        {
            using var doc = JsonDocument.Parse(json!);
            mode = ParseTriggerMode(doc.RootElement);
        }
        catch (JsonException) { /* keep OnTrue */ }

        return new ExpressionTriggerConfig(expr, mode);
    }

    /// <summary>
    /// Reads the optional <c>mode</c> field (Conditional + Expression triggers).
    /// An absent or unrecognized value (case-insensitive) yields
    /// <see cref="TriggerMode.OnTrue"/>, so pre-WhileTrue configs are unchanged.
    /// </summary>
    private static TriggerMode ParseTriggerMode(JsonElement root)
    {
        var raw = root.TryGetProperty("mode", out var m) ? m.GetString() : null;
        return string.Equals(raw?.Trim(), "WhileTrue", StringComparison.OrdinalIgnoreCase)
            ? TriggerMode.WhileTrue
            : TriggerMode.OnTrue;
    }

    private static IntervalTriggerConfig? ParseIntervalTrigger(string? json)
    {
        if (string.IsNullOrEmpty(json)) return null;
        try
        {
            var doc = JsonDocument.Parse(json);
            var ms = doc.RootElement.GetProperty("intervalMs").GetInt64();
            return new IntervalTriggerConfig(TimeSpan.FromMilliseconds(ms));
        }
        catch { return null; }
    }

    private static ValueChangeTriggerConfig? ParseValueChangeTrigger(string? json)
    {
        // Share the monitored-attribute parser with InstanceActor's routing map (P2) so both
        // agree on which attribute this trigger reacts to.
        return TriggerRouting.TryReadAttributeName(json, out var attr)
            ? new ValueChangeTriggerConfig(attr)
            : null;
    }

    private static ConditionalTriggerConfig? ParseConditionalTrigger(string? json)
    {
        if (string.IsNullOrEmpty(json)) return null;
        // Attribute name comes from the shared parser (P2); operator/threshold/mode stay local.
        if (!TriggerRouting.TryReadAttributeName(json, out var attr)) return null;
        try
        {
            var doc = JsonDocument.Parse(json);
            var op = doc.RootElement.GetProperty("operator").GetString()!;
            var threshold = doc.RootElement.GetProperty("threshold").GetDouble();
            return new ConditionalTriggerConfig(
                attr, op, threshold, ParseTriggerMode(doc.RootElement));
        }
        catch { return null; }
    }

    // ── Internal messages ──

    internal sealed class IntervalTick
    {
        public static readonly IntervalTick Instance = new();
        private IntervalTick() { }
    }

    internal sealed class WhileTrueTick
    {
        public static readonly WhileTrueTick Instance = new();
        private WhileTrueTick() { }
    }

    internal record ScriptExecutionCompleted(string ScriptName, bool Success, string? Error);

    /// <summary>
    /// Piped back to self from an off-dispatcher trigger-expression evaluation (P1);
    /// carries the boolean truth value so the OnTrue/WhileTrue edge logic runs on
    /// the actor thread.
    /// </summary>
    private sealed record ExpressionEvalResult(bool Result);

    /// <summary>
    /// Piped back to self when the off-dispatcher evaluation TASK itself faults
    /// (e.g. ObjectDisposedException from a disposed ScriptExecutionScheduler
    /// during shutdown) — the inner body's catch never sees that. Without this
    /// mapping the actor receives an unhandled Status.Failure and _evalInFlight
    /// stays true forever, permanently parking the expression trigger (N2).
    /// </summary>
    internal sealed record ExpressionEvalFailed(Exception Cause);
}

// ── Trigger config types ──

/// <summary>
/// When a Conditional/Expression trigger fires. <see cref="OnTrue"/> fires once
/// as the condition becomes true; <see cref="WhileTrue"/> additionally re-fires
/// on a timer (cadence = the script's MinTimeBetweenRuns) until it goes false.
/// </summary>
internal enum TriggerMode { OnTrue, WhileTrue }

internal record IntervalTriggerConfig(TimeSpan Interval) : ScriptTriggerConfig;
internal record ValueChangeTriggerConfig(string AttributeName) : ScriptTriggerConfig;
internal record ConditionalTriggerConfig(string AttributeName, string Operator, double Threshold, TriggerMode Mode) : ScriptTriggerConfig;
internal record ExpressionTriggerConfig(string Expression, TriggerMode Mode) : ScriptTriggerConfig;
internal abstract record ScriptTriggerConfig;
