using System.Globalization;
using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
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.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;

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

/// <summary>
/// WP-15: Script Actor and Script Execution Actor tests.
/// WP-20: Recursion limit tests.
/// WP-22: Tell vs Ask convention tests.
/// WP-32: Script error handling tests.
/// </summary>
public class ScriptActorTests : TestKit, IDisposable
{
    private readonly SharedScriptLibrary _sharedLibrary;
    private readonly SiteRuntimeOptions _options;
    private readonly ScriptCompilationService _compilationService;

    // Gitea #16/#18: every ScriptActor built here runs its trigger-expression
    // evaluation and spawned script bodies on THIS class's own scheduler, never the
    // process-wide singleton. That is what makes the faulted-task test below
    // un-leakable across the assembly: any worker a test strands (or the 30 s bounded
    // EvalGate wait leaves parked) lives on this instance, which is disposed at class
    // teardown, so it can never starve unrelated test classes' script executions.
    private readonly ScriptExecutionScheduler _scheduler;

    public ScriptActorTests()
    {
        _compilationService = new ScriptCompilationService(
            NullLogger<ScriptCompilationService>.Instance);
        _sharedLibrary = new SharedScriptLibrary(
            _compilationService, NullLogger<SharedScriptLibrary>.Instance);
        _options = new SiteRuntimeOptions
        {
            MaxScriptCallDepth = 10,
            ScriptExecutionTimeoutSeconds = 30
        };
        _scheduler = new ScriptExecutionScheduler(_options.ScriptExecutionThreadCount);
    }

    void IDisposable.Dispose()
    {
        Shutdown();
        _scheduler.Dispose();
    }

    private Script<object?> CompileScript(string code)
    {
        var scriptOptions = ScriptOptions.Default
            .WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
            .WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");

        var script = CSharpScript.Create<object?>(code, scriptOptions, typeof(ScriptGlobals));
        script.Compile();
        return script;
    }

    [Fact]
    public void ScriptActor_CallScript_ReturnsResult()
    {
        var compiled = CompileScript("42");
        var scriptConfig = new ResolvedScript
        {
            CanonicalName = "GetAnswer",
            Code = "42"
        };

        var instanceActor = CreateTestProbe();
        var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
            "GetAnswer",
            "TestInstance",
            instanceActor.Ref,
            compiled,
            scriptConfig,
            _sharedLibrary,
            _options,
            NullLogger<ScriptActor>.Instance,
            null,   // compiledTriggerExpression
            null,   // initialAttributes
            null,   // healthCollector
            null,   // serviceProvider
            _scheduler)));

        // Ask pattern (WP-22) for CallScript
        scriptActor.Tell(new ScriptCallRequest("GetAnswer", null, 0, "corr-1"));

        var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
        Assert.True(result.Success, $"Script call failed: {result.ErrorMessage}");
        Assert.Equal(42, result.ReturnValue);
    }

    [Fact]
    public void ScriptActor_CallScript_WithParameters_Works()
    {
        var compiled = CompileScript("(int)Parameters[\"x\"] + (int)Parameters[\"y\"]");
        var scriptConfig = new ResolvedScript
        {
            CanonicalName = "Add",
            Code = "(int)Parameters[\"x\"] + (int)Parameters[\"y\"]"
        };

        var instanceActor = CreateTestProbe();
        var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
            "Add",
            "TestInstance",
            instanceActor.Ref,
            compiled,
            scriptConfig,
            _sharedLibrary,
            _options,
            NullLogger<ScriptActor>.Instance,
            null,   // compiledTriggerExpression
            null,   // initialAttributes
            null,   // healthCollector
            null,   // serviceProvider
            _scheduler)));

        var parameters = new Dictionary<string, object?> { ["x"] = 3, ["y"] = 4 };
        scriptActor.Tell(new ScriptCallRequest("Add", parameters, 0, "corr-2"));

        var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
        Assert.True(result.Success);
        Assert.Equal(7, result.ReturnValue);
    }

    [Fact]
    public void ScriptActor_NullCompiledScript_ReturnsError()
    {
        var scriptConfig = new ResolvedScript
        {
            CanonicalName = "Broken",
            Code = ""
        };

        var instanceActor = CreateTestProbe();
        var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
            "Broken",
            "TestInstance",
            instanceActor.Ref,
            null, // no compiled script
            scriptConfig,
            _sharedLibrary,
            _options,
            NullLogger<ScriptActor>.Instance,
            null,   // compiledTriggerExpression
            null,   // initialAttributes
            null,   // healthCollector
            null,   // serviceProvider
            _scheduler)));

        scriptActor.Tell(new ScriptCallRequest("Broken", null, 0, "corr-3"));

        var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(5));
        Assert.False(result.Success);
        Assert.Contains("not compiled", result.ErrorMessage);
    }

    [Fact]
    public void ScriptActor_ValueChangeTrigger_SpawnsExecution()
    {
        var compiled = CompileScript("\"triggered\"");
        var scriptConfig = new ResolvedScript
        {
            CanonicalName = "OnChange",
            Code = "\"triggered\"",
            TriggerType = "ValueChange",
            TriggerConfiguration = "{\"attributeName\":\"Temperature\"}"
        };

        var instanceActor = CreateTestProbe();
        var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
            "OnChange",
            "TestInstance",
            instanceActor.Ref,
            compiled,
            scriptConfig,
            _sharedLibrary,
            _options,
            NullLogger<ScriptActor>.Instance,
            null,   // compiledTriggerExpression
            null,   // initialAttributes
            null,   // healthCollector
            null,   // serviceProvider
            _scheduler)));

        // Send an attribute change that matches the trigger
        scriptActor.Tell(new AttributeValueChanged(
            "TestInstance", "Temperature", "Temperature", "100.0", "Good", DateTimeOffset.UtcNow));

        // The script should execute (we can't easily verify the output since it's fire-and-forget)
        // But we can verify the actor doesn't crash
        ExpectNoMsg(TimeSpan.FromSeconds(1));
    }

    [Fact]
    public void ScriptActor_MinTimeBetweenRuns_SkipsIfTooSoon()
    {
        var compiled = CompileScript("\"ok\"");
        var scriptConfig = new ResolvedScript
        {
            CanonicalName = "Throttled",
            Code = "\"ok\"",
            TriggerType = "ValueChange",
            TriggerConfiguration = "{\"attributeName\":\"Temp\"}",
            MinTimeBetweenRuns = TimeSpan.FromMinutes(10) // long minimum
        };

        var instanceActor = CreateTestProbe();
        var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
            "Throttled",
            "TestInstance",
            instanceActor.Ref,
            compiled,
            scriptConfig,
            _sharedLibrary,
            _options,
            NullLogger<ScriptActor>.Instance,
            null,   // compiledTriggerExpression
            null,   // initialAttributes
            null,   // healthCollector
            null,   // serviceProvider
            _scheduler)));

        // First trigger -- should execute
        scriptActor.Tell(new AttributeValueChanged(
            "TestInstance", "Temp", "Temp", "1", "Good", DateTimeOffset.UtcNow));

        // Second trigger immediately -- should be skipped due to min time
        scriptActor.Tell(new AttributeValueChanged(
            "TestInstance", "Temp", "Temp", "2", "Good", DateTimeOffset.UtcNow));

        // No crash expected
        ExpectNoMsg(TimeSpan.FromSeconds(1));
    }

    [Fact]
    public void ScriptActor_WP32_ScriptFailure_DoesNotDisable()
    {
        // Script that throws an exception
        var compiled = CompileScript("throw new System.Exception(\"boom\")");
        var scriptConfig = new ResolvedScript
        {
            CanonicalName = "Failing",
            Code = "throw new System.Exception(\"boom\")"
        };

        var instanceActor = CreateTestProbe();
        var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
            "Failing",
            "TestInstance",
            instanceActor.Ref,
            compiled,
            scriptConfig,
            _sharedLibrary,
            _options,
            NullLogger<ScriptActor>.Instance,
            null,   // compiledTriggerExpression
            null,   // initialAttributes
            null,   // healthCollector
            null,   // serviceProvider
            _scheduler)));

        // First call -- fails
        scriptActor.Tell(new ScriptCallRequest("Failing", null, 0, "corr-fail-1"));
        var result1 = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
        Assert.False(result1.Success);

        // Second call -- should still work (script not disabled after failure)
        scriptActor.Tell(new ScriptCallRequest("Failing", null, 0, "corr-fail-2"));
        var result2 = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
        Assert.False(result2.Success); // Still fails, but the actor is still alive
    }

    // ── WhileTrue trigger mode (Conditional + Expression) ──────────────────
    //
    // A fired script runs `Instance.SetAttribute("Fired", "1")`, which the
    // Instance Actor receives as a SetStaticAttributeCommand. The probe stands
    // in for the Instance Actor: an auto-pilot replies so each execution
    // completes promptly (freeing the script-execution scheduler), while every
    // command remains observable via ExpectMsg — one command per script firing.

    private const string FiringScriptCode = "await Instance.SetAttribute(\"Fired\", \"1\")";

    /// <summary>Builds a ScriptActor whose script fires one observable command per run.</summary>
    private (IActorRef Actor, TestProbe Instance) CreateTriggeredActor(
        string name,
        string triggerType,
        string triggerConfig,
        TimeSpan? minTimeBetweenRuns,
        Script<object?>? triggerExpression = null)
    {
        var compiled = CompileScript(FiringScriptCode);
        var scriptConfig = new ResolvedScript
        {
            CanonicalName = name,
            Code = FiringScriptCode,
            TriggerType = triggerType,
            TriggerConfiguration = triggerConfig,
            MinTimeBetweenRuns = minTimeBetweenRuns
        };

        var instance = CreateTestProbe();
        instance.SetAutoPilot(new DelegateAutoPilot((sender, message) =>
        {
            if (message is SetStaticAttributeCommand cmd)
            {
                sender.Tell(new SetStaticAttributeResponse(
                    cmd.CorrelationId, cmd.InstanceUniqueName, cmd.AttributeName,
                    true, null, DateTimeOffset.UtcNow));
            }
            return AutoPilot.KeepRunning;
        }));

        var actor = ActorOf(Props.Create(() => new ScriptActor(
            name,
            "TestInstance",
            instance.Ref,
            compiled,
            scriptConfig,
            _sharedLibrary,
            _options,
            NullLogger<ScriptActor>.Instance,
            triggerExpression,
            null,
            null,
            null,
            _scheduler)));

        return (actor, instance);
    }

    private AttributeValueChanged Change(string attribute, object? value) =>
        new("TestInstance", attribute, attribute, value, "Good", DateTimeOffset.UtcNow);

    [Fact]
    public void ConditionalTrigger_NonNumericFallback_IsCultureInvariant()
    {
        // EvaluateCondition runs on a dispatcher thread in the live path, whose
        // CurrentCulture a test cannot deterministically set — so the culture-
        // invariance of the string fallback is verified by calling the pure helper
        // directly under a forced de-DE culture.
        var original = CultureInfo.CurrentCulture;
        try
        {
            CultureInfo.CurrentCulture = new CultureInfo("de-DE"); // 1234.5 -> "1.234,5"

            // "1.234,5" is the de-DE rendering of 1234.5: it FAILS the invariant
            // numeric parse (decimal point then group separator) and falls through to
            // the string comparison. The old fallback compared against the culture-
            // sensitive Threshold.ToString() ("1.234,5" under de-DE) and matched (fired);
            // the invariant fallback compares against "1234.5" and must NOT match.
            var config = new ConditionalTriggerConfig("Temp", "==", 1234.5, TriggerMode.OnTrue);
            Assert.False(ScriptActor.EvaluateCondition(config, "1.234,5"));

            // Sanity: a value that genuinely equals the invariant rendering still matches.
            Assert.True(ScriptActor.EvaluateCondition(
                new ConditionalTriggerConfig("Temp", "==", 1234.5, TriggerMode.OnTrue), "1234.5"));
        }
        finally
        {
            CultureInfo.CurrentCulture = original;
        }
    }

    private Script<object?> CompileTriggerExpression(string expression) =>
        _compilationService.CompileTriggerExpression("trigger-expr", expression).CompiledScript!;

    /// <summary>
    /// Compiles a trigger expression WITHOUT the trust validator so a test can use
    /// <c>System.Threading.Thread</c> (forbidden in real scripts) to observe which
    /// thread the evaluation runs on. Mirrors the real compile against
    /// <see cref="TriggerExpressionGlobals"/>.
    /// </summary>
    private static Script<object?> CompileRawTriggerExpression(string expression)
    {
        var opts = ScriptOptions.Default
            // The test assembly is referenced so a raw-compiled expression may reference
            // test-only hooks like EvalGate (used by the faulted-task N2 test).
            .WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly, typeof(ScriptActorTests).Assembly)
            .WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
        var s = CSharpScript.Create<object?>(expression, opts, typeof(TriggerExpressionGlobals));
        s.Compile();
        return s;
    }

    [Fact]
    public void ExpressionEvalTaskFault_ClearsInFlight_AndDrainsPendingEvaluation()
    {
        EvalGate.Reset(); // fresh gate + zeroed counter; never inherit a prior run's state
        var expr = CompileRawTriggerExpression(
            "ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors.EvalGate.Block()");
        var (actor, _) = CreateTriggeredActor(
            "ExprFault", "Expression", "{\"expression\":\"true\",\"mode\":\"OnTrue\"}", null, expr);
        try
        {
            actor.Tell(Change("A", "1"));                              // eval starts off-thread and BLOCKS → _evalInFlight = true
            AwaitAssert(() => Assert.Equal(1, EvalGate.Entries), TimeSpan.FromSeconds(10));

            // WP3.1: the blocked evaluation no longer occupies a script-execution worker at
            // all — it runs on the shared thread pool behind TriggerEvalGate, which is the
            // whole point of finding #4. The blocking pool must be completely idle here; the
            // pre-WP3.1 assertion was the opposite (BusyThreadCount == 1).
            Assert.Equal(0, _scheduler.BusyThreadCount);
            Assert.Equal(0, _scheduler.QueueDepth);

            actor.Tell(Change("A", "2"));                              // coalesces → _evalPending = true

            // Simulate the faulted scheduler task the PipeTo failure mapping now surfaces
            // (e.g. ObjectDisposedException from a disposed ScriptExecutionScheduler).
            actor.Tell(new ScriptActor.ExpressionEvalFailed(new ObjectDisposedException("scheduler")));

            // In-flight cleared + pending drained ⇒ a SECOND evaluation starts and blocks too.
            AwaitAssert(() => Assert.Equal(2, EvalGate.Entries), TimeSpan.FromSeconds(10));
        }
        finally
        {
            // Release generously (one per worker) so every parked/queued Block returns
            // at once and this class's scheduler drains promptly at teardown. The old
            // Release(Entries) under-released in exactly the failure case (Entries read
            // as 1 while a second eval was still queued), stranding a worker; the
            // bounded 30 s wait in Block is the ultimate backstop.
            EvalGate.Gate.Release(_options.ScriptExecutionThreadCount);
        }
    }

    [Fact]
    public void ExpressionTrigger_EvaluatesOffTheBlockingScriptPool_AndStillFires()
    {
        // WP3.1 retarget of the former P1 assertion, whose sense is deliberately INVERTED.
        //
        // P1 moved trigger-expression evaluation off the actor's dispatcher thread and onto
        // the dedicated script-execution scheduler, and this test asserted exactly that
        // ("evaluated on a script-execution-* thread"). WP3.1 (finding #4) proved that
        // destination wrong: sharing the blocking pool meant N blocked script bodies stalled
        // every Expression trigger on the node indefinitely. Evaluations are non-blocking by
        // construction, so they now run as plain async work on the shared .NET thread pool
        // behind TriggerEvalGate.
        //
        // The expression is therefore TRUE only when evaluated OFF a script-execution thread —
        // still off the dispatcher (the P1 property, covered by the coalescing/PipeTo tests),
        // and now provably off the blocking pool too.
        var expr = CompileRawTriggerExpression(
            "System.Threading.Thread.CurrentThread.Name == null || " +
            "!System.Threading.Thread.CurrentThread.Name.StartsWith(\"script-execution-\")");
        var (actor, instance) = CreateTriggeredActor(
            "ExprThread",
            "Expression",
            "{\"expression\":\"true\",\"mode\":\"OnTrue\"}",
            minTimeBetweenRuns: null,
            expr);

        actor.Tell(Change("Any", "1"));
        instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(10)); // fired ⇒ evaluated off the blocking pool
    }

    [Fact]
    public void ScriptActor_ConditionalWhileTrue_FiresOnEdgeThenReFiresWhileConditionHolds()
    {
        // WhileTrue re-fire cadence is the script's MinTimeBetweenRuns.
        var (actor, instance) = CreateTriggeredActor(
            "CondWhile",
            "Conditional",
            "{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
            TimeSpan.FromMilliseconds(300));

        // Temp 100 > 50 -> false->true edge: fire immediately.
        actor.Tell(Change("Temp", "100"));
        instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire

        // Then the timer re-fires while the condition still holds.
        instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 1
        instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 2
    }

    [Fact]
    public void ScriptActor_ConditionalWhileTrue_StopsReFiringWhenConditionGoesFalse()
    {
        var (actor, instance) = CreateTriggeredActor(
            "CondStop",
            "Conditional",
            "{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
            TimeSpan.FromMilliseconds(300));

        actor.Tell(Change("Temp", "100"));
        instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
        instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // at least one tick

        // Temp 10 -> condition false: the re-fire timer stops.
        actor.Tell(Change("Temp", "10"));
        instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
        instance.ExpectNoMsg(TimeSpan.FromMilliseconds(700));   // re-firing has stopped
    }

    [Fact]
    public void ScriptActor_ConditionalWhileTrue_ReArmsAfterConditionFalseThenTrueAgain()
    {
        var (actor, instance) = CreateTriggeredActor(
            "CondReArm",
            "Conditional",
            "{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
            TimeSpan.FromMilliseconds(300));

        actor.Tell(Change("Temp", "100"));            // true edge -> fire
        instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
        actor.Tell(Change("Temp", "10"));             // false -> stop
        instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
        instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));

        actor.Tell(Change("Temp", "100"));            // false->true again: re-arm + fire
        instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
    }

    [Fact]
    public void ScriptActor_ConditionalWhileTrue_WithoutMinTimeBetweenRuns_FiresOnceOnly()
    {
        // No MinTimeBetweenRuns -> no re-fire interval: degrades to a single edge fire.
        var (actor, instance) = CreateTriggeredActor(
            "CondNoInterval",
            "Conditional",
            "{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
            minTimeBetweenRuns: null);

        actor.Tell(Change("Temp", "100"));
        instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
        instance.ExpectNoMsg(TimeSpan.FromMilliseconds(900));                   // no repeats
    }

    [Fact]
    public void ScriptActor_ConditionalOnTrue_FiresOnEachChangeWhileTrue_NoTimer()
    {
        // Regression: OnTrue (the existing behavior) fires per matching change
        // and never re-fires on a timer of its own.
        var (actor, instance) = CreateTriggeredActor(
            "CondOnTrue",
            "Conditional",
            "{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"OnTrue\"}",
            minTimeBetweenRuns: null);

        actor.Tell(Change("Temp", "100"));
        instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
        actor.Tell(Change("Temp", "101"));
        instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));

        instance.ExpectNoMsg(TimeSpan.FromMilliseconds(600)); // no self-driven re-fire
    }

    [Fact]
    public void ScriptActor_ExpressionWhileTrue_ReFiresWhileExpressionHolds()
    {
        var triggerExpr = CompileTriggerExpression("Attributes[\"Active\"]?.ToString() == \"yes\"");
        var (actor, instance) = CreateTriggeredActor(
            "ExprWhile",
            "Expression",
            "{\"expression\":\"Attributes[\\\"Active\\\"]?.ToString() == \\\"yes\\\"\",\"mode\":\"WhileTrue\"}",
            TimeSpan.FromMilliseconds(300),
            triggerExpr);

        actor.Tell(Change("Active", "yes"));
        instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
        instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 1

        actor.Tell(Change("Active", "no"));
        instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
        instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    [Fact]
    public void ScriptActor_ExpressionOnTrue_FiresOncePerFalseToTrueEdge()
    {
        // Regression: OnTrue expression triggers stay edge-triggered.
        var triggerExpr = CompileTriggerExpression("Attributes[\"Active\"]?.ToString() == \"yes\"");
        var (actor, instance) = CreateTriggeredActor(
            "ExprOnTrue",
            "Expression",
            "{\"expression\":\"Attributes[\\\"Active\\\"]?.ToString() == \\\"yes\\\"\",\"mode\":\"OnTrue\"}",
            minTimeBetweenRuns: null,
            triggerExpr);

        actor.Tell(Change("Active", "yes"));
        instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
        actor.Tell(Change("Active", "yes"));                                    // still true, no edge
        instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));

        actor.Tell(Change("Active", "no"));                                     // -> false
        actor.Tell(Change("Active", "yes"));                                    // false->true edge again
        instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
    }
}

/// <summary>
/// Test-only hook referenced by a raw-compiled trigger expression: <see cref="Block"/>
/// blocks the calling script-scheduler thread on <see cref="Gate"/> so a test can hold an
/// evaluation "in flight" and count how many evaluations have entered. Used by
/// <c>ExpressionEvalTaskFault_ClearsInFlight_AndDrainsPendingEvaluation</c>.
/// </summary>
public static class EvalGate
{
    private static SemaphoreSlim _gate = new(0);
    private static int _entries;

    /// <summary>The semaphore that script-scheduler threads park on inside <see cref="Block"/>.</summary>
    public static SemaphoreSlim Gate => _gate;

    /// <summary>Number of evaluations that have entered <see cref="Block"/> since the last <see cref="Reset"/>.</summary>
    public static int Entries => Volatile.Read(ref _entries);

    /// <summary>
    /// Resets the gate to a fresh, permit-free state and zeroes the entry counter.
    /// Called at the start of the test so counts never carry across runs — the old
    /// static leaked <c>_entries</c> from whatever compiled an expression before it
    /// (Gitea #16).
    /// </summary>
    public static void Reset()
    {
        _gate = new SemaphoreSlim(0);
        Volatile.Write(ref _entries, 0);
    }

    /// <summary>
    /// Counts the entry, then blocks the calling script-scheduler thread until a permit
    /// is released. The wait is BOUNDED (30 s): even if a test releases too few permits,
    /// a stranded evaluation frees its worker instead of parking it for the rest of the
    /// process — the leak mechanism behind Gitea #16. Returns false so a trigger
    /// expression built on it evaluates to "not fired".
    /// </summary>
    public static bool Block()
    {
        Interlocked.Increment(ref _entries);
        _gate.Wait(TimeSpan.FromSeconds(30));
        return false;
    }
}
