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.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;

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

/// <summary>
/// WP-16: Alarm Actor tests — value match, range violation, rate of change.
/// WP-21: Alarm on-trigger call direction tests.
/// </summary>
public class AlarmActorTests : TestKit, IDisposable
{
    private readonly SharedScriptLibrary _sharedLibrary;
    private readonly SiteRuntimeOptions _options;
    private readonly ScriptCompilationService _compilationService;

    public AlarmActorTests()
    {
        _compilationService = new ScriptCompilationService(
            NullLogger<ScriptCompilationService>.Instance);
        _sharedLibrary = new SharedScriptLibrary(
            _compilationService, NullLogger<SharedScriptLibrary>.Instance);
        _options = new SiteRuntimeOptions();
    }

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

    [Fact]
    public void AlarmActor_ValueMatch_ActivatesOnMatch()
    {
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "HighTemp",
            TriggerType = "ValueMatch",
            TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
            PriorityLevel = 1
        };

        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "HighTemp", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance)));

        // Send value that matches
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));

        // Instance Actor should receive AlarmStateChanged
        var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Active, msg.State);
        Assert.Equal("HighTemp", msg.AlarmName);
    }

    [Fact]
    public void AlarmActor_ValueMatch_ClearsOnNonMatch()
    {
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "HighTemp",
            TriggerType = "ValueMatch",
            TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
            PriorityLevel = 1
        };

        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "HighTemp", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance)));

        // Activate
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
        var activateMsg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Active, activateMsg.State);

        // Clear
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Normal", "Good", DateTimeOffset.UtcNow));
        var clearMsg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Normal, clearMsg.State);
    }

    [Fact]
    public void AlarmActor_ComputedAlarm_CarriesUnifiedConditionState()
    {
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "HighTemp",
            TriggerType = "ValueMatch",
            TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
            PriorityLevel = 700
        };

        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "HighTemp", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance)));

        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));

        var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmKind.Computed, msg.Kind);             // computed origin
        Assert.Equal(msg.Priority, msg.Condition.Severity);     // severity tracks priority
        Assert.Equal(700, msg.Condition.Severity);
        Assert.True(msg.Condition.Active);                      // active alarm → condition active
        Assert.True(msg.Condition.Acknowledged);                // computed alarms are auto-acked
        Assert.Equal(string.Empty, msg.SourceReference);        // no native source reference
    }

    [Fact]
    public void AlarmActor_RangeViolation_ActivatesOutsideRange()
    {
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "TempRange",
            TriggerType = "RangeViolation",
            TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"min\":0,\"max\":100}",
            PriorityLevel = 2
        };

        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "TempRange", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance)));

        // Value within range -- no alarm
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Temperature", "Temperature", "50", "Good", DateTimeOffset.UtcNow));
        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));

        // Value outside range -- alarm activates
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow));
        var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Active, msg.State);
    }

    [Fact]
    public void AlarmActor_RangeViolation_ClearsWhenBackInRange()
    {
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "TempRange",
            TriggerType = "RangeViolation",
            TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"min\":0,\"max\":100}",
            PriorityLevel = 2
        };

        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "TempRange", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance)));

        // Activate
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow));
        instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));

        // Clear
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Temperature", "Temperature", "75", "Good", DateTimeOffset.UtcNow));
        var clearMsg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Normal, clearMsg.State);
    }

    [Fact]
    public void AlarmActor_IgnoresUnmonitoredAttributes()
    {
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "TempAlarm",
            TriggerType = "ValueMatch",
            TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"matchValue\":\"100\"}",
            PriorityLevel = 1
        };

        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance)));

        // Send change for a different attribute
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Pressure", "Pressure", "100", "Good", DateTimeOffset.UtcNow));

        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    [Fact]
    public void AlarmActor_DoesNotReTrigger_WhenAlreadyActive()
    {
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "TempAlarm",
            TriggerType = "ValueMatch",
            TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Bad\"}",
            PriorityLevel = 1
        };

        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance)));

        // First trigger
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow));
        instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));

        // Second trigger with same value -- should NOT re-trigger
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow));
        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    [Fact]
    public void AlarmActor_StartsNormal_OnRestart()
    {
        // Per design: on restart, alarm starts normal, re-evaluates from incoming values
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "RestartAlarm",
            TriggerType = "ValueMatch",
            TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Bad\"}",
            PriorityLevel = 1
        };

        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "RestartAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance)));

        // A "Good" value should not trigger since alarm starts Normal
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Good", "Good", DateTimeOffset.UtcNow));
        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    [Fact]
    public void AlarmActor_NoClearScript_OnDeactivation()
    {
        // WP-16: On clear, NO script is executed. Only on activate.
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "ClearTest",
            TriggerType = "ValueMatch",
            TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Bad\"}",
            PriorityLevel = 1
        };

        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "ClearTest", "Pump1", instanceProbe.Ref, alarmConfig,
            null, // no on-trigger script
            _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance)));

        // Activate
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow));
        instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));

        // Clear -- should send state change but no script execution
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Good", "Good", DateTimeOffset.UtcNow));
        var clearMsg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Normal, clearMsg.State);

        // No additional messages (no script execution side effects)
        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    // ── RateOfChange ───────────────────────────────────────────────────────

    /// <summary>
    /// Builds a RateOfChange config JSON with the given threshold (units/sec),
    /// window (seconds), and direction. Used by the rate-of-change tests.
    /// </summary>
    private static string RocConfig(double thresholdPerSecond, double windowSeconds, string direction) =>
        $"{{\"attributeName\":\"Pressure\",\"thresholdPerSecond\":{thresholdPerSecond.ToString(System.Globalization.CultureInfo.InvariantCulture)},\"windowSeconds\":{windowSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)},\"direction\":\"{direction}\"}}";

    private IActorRef SpawnRocAlarm(string config, TestProbe instanceProbe)
    {
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "RocAlarm",
            TriggerType = "RateOfChange",
            TriggerConfiguration = config,
            PriorityLevel = 3
        };
        return ActorOf(Props.Create(() => new AlarmActor(
            "RocAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance)));
    }

    private static AttributeValueChanged PressureSample(double value, DateTimeOffset ts) =>
        new("Pump1", "Pressure", "Pressure",
            value.ToString(System.Globalization.CultureInfo.InvariantCulture),
            "Good", ts);

    [Fact]
    public void AlarmActor_RateOfChange_Either_ActivatesOnRapidRise()
    {
        var instanceProbe = CreateTestProbe();
        // 50 units/sec threshold, 2 sec window
        var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);

        var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
        // First sample establishes the window baseline; needs ≥2 samples to compute a rate.
        alarm.Tell(PressureSample(0, t0));
        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));

        // 100 over 1 sec = 100 units/sec > 50 threshold → activate
        alarm.Tell(PressureSample(100, t0.AddSeconds(1)));

        var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Active, msg.State);
    }

    [Fact]
    public void AlarmActor_RateOfChange_Either_ActivatesOnRapidFall()
    {
        var instanceProbe = CreateTestProbe();
        var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);

        var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
        alarm.Tell(PressureSample(100, t0));
        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));

        // -100 over 1 sec → |rate| = 100 > 50 → activate (Either covers both signs)
        alarm.Tell(PressureSample(0, t0.AddSeconds(1)));

        var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Active, msg.State);
    }

    [Fact]
    public void AlarmActor_RateOfChange_Either_DoesNotActivateWhenBelowThreshold()
    {
        var instanceProbe = CreateTestProbe();
        var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);

        var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
        alarm.Tell(PressureSample(0, t0));
        // 10 over 1 sec = 10 units/sec < 50 threshold → no alarm
        alarm.Tell(PressureSample(10, t0.AddSeconds(1)));

        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    [Fact]
    public void AlarmActor_RateOfChange_Rising_IgnoresFallingSpikes()
    {
        var instanceProbe = CreateTestProbe();
        var alarm = SpawnRocAlarm(RocConfig(50, 2, "rising"), instanceProbe);

        var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
        alarm.Tell(PressureSample(100, t0));
        // -100 over 1 sec → would trigger Either, but Rising only fires on positive rate
        alarm.Tell(PressureSample(0, t0.AddSeconds(1)));

        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    [Fact]
    public void AlarmActor_RateOfChange_Falling_IgnoresRisingSpikes()
    {
        var instanceProbe = CreateTestProbe();
        var alarm = SpawnRocAlarm(RocConfig(50, 2, "falling"), instanceProbe);

        var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
        alarm.Tell(PressureSample(0, t0));
        alarm.Tell(PressureSample(100, t0.AddSeconds(1)));

        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    [Fact]
    public void AlarmActor_RateOfChange_Falling_ActivatesOnFallingRate()
    {
        var instanceProbe = CreateTestProbe();
        var alarm = SpawnRocAlarm(RocConfig(50, 2, "falling"), instanceProbe);

        var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
        alarm.Tell(PressureSample(100, t0));
        alarm.Tell(PressureSample(0, t0.AddSeconds(1))); // -100/sec, |rate| > threshold, falling

        var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Active, msg.State);
    }

    [Fact]
    public void AlarmActor_RateOfChange_SingleSample_DoesNotActivate()
    {
        // The evaluator needs at least two samples in the window to compute a rate.
        var instanceProbe = CreateTestProbe();
        var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);

        var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
        alarm.Tell(PressureSample(1000, t0));

        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    [Fact]
    public void AlarmActor_RateOfChange_WindowRollsOff_OldSamplesDiscarded()
    {
        // 1-second window. Sample at t=0 with value 0 should fall out before the
        // sample at t=3, so the in-window history is just the two recent samples
        // (t=2.5, v=99) and (t=3, v=100) → rate = 1 unit / 0.5s = 2/sec — below
        // the threshold, so no alarm even though the long-term delta is huge.
        var instanceProbe = CreateTestProbe();
        var alarm = SpawnRocAlarm(RocConfig(50, 1, "either"), instanceProbe);

        var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
        alarm.Tell(PressureSample(0, t0));
        alarm.Tell(PressureSample(99, t0.AddSeconds(2.5)));
        alarm.Tell(PressureSample(100, t0.AddSeconds(3)));

        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    [Fact]
    public void AlarmActor_RateOfChange_ClearsWhenRateDropsBack()
    {
        var instanceProbe = CreateTestProbe();
        var alarm = SpawnRocAlarm(RocConfig(50, 1, "either"), instanceProbe);

        var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
        // Spike: activate
        alarm.Tell(PressureSample(0, t0));
        alarm.Tell(PressureSample(100, t0.AddSeconds(0.5))); // 200/sec > 50
        var activate = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Active, activate.State);

        // Now sample again well past the 1-second window with only a tiny change
        // → rate falls below threshold → clears.
        alarm.Tell(PressureSample(101, t0.AddSeconds(3)));

        var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Normal, clear.State);
    }

    // ── Legacy JSON aliases & not-equals prefix ────────────────────────────

    [Fact]
    public void AlarmActor_ValueMatch_LegacyAttributeAndValueKeys_StillFire()
    {
        // Old configs used "attribute" / "value" before the canonical names landed.
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "Legacy",
            TriggerType = "ValueMatch",
            TriggerConfiguration = "{\"attribute\":\"Status\",\"value\":\"Critical\"}",
            PriorityLevel = 1
        };

        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "Legacy", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance)));

        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));

        var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Active, msg.State);
    }

    [Fact]
    public void AlarmActor_ValueMatch_NotEqualsPrefix_FiresWhenValueDiffers()
    {
        // matchValue "!=Good" means: alarm when Status is anything other than Good.
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "Inverted",
            TriggerType = "ValueMatch",
            TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"!=Good\"}",
            PriorityLevel = 1
        };

        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "Inverted", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance)));

        // Status=Critical (not "Good") → alarm activates
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
        var activate = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Active, activate.State);

        // Status=Good → alarm clears
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Good", "Critical", DateTimeOffset.UtcNow));
        var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Normal, clear.State);
    }

    [Fact]
    public void AlarmActor_RangeViolation_LegacyLowHighKeys_StillFire()
    {
        // Older configs used "low" / "high" instead of the current "min" / "max".
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "Legacy",
            TriggerType = "RangeViolation",
            TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"low\":0,\"high\":100}",
            PriorityLevel = 1
        };

        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "Legacy", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance)));

        // Within range → no alarm
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Temperature", "Temperature", "50", "Good", DateTimeOffset.UtcNow));
        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));

        // Outside range → activate
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow));
        var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Active, msg.State);
    }

    // ── HiLo ───────────────────────────────────────────────────────────────

    /// <summary>Spawns a HiLo alarm with the given JSON config and alarm-level priority fallback.</summary>
    private IActorRef SpawnHiLoAlarm(string config, TestProbe instanceProbe, int priority = 500)
    {
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "TempAlarm",
            TriggerType = "HiLo",
            TriggerConfiguration = config,
            PriorityLevel = priority
        };
        return ActorOf(Props.Create(() => new AlarmActor(
            "TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance)));
    }

    private static AttributeValueChanged TempSample(double value) =>
        new("Pump1", "Temperature", "Temperature",
            value.ToString(System.Globalization.CultureInfo.InvariantCulture),
            "Good", DateTimeOffset.UtcNow);

    [Fact]
    public void AlarmActor_HiLo_EntersHigh_WhenValueCrossesHi()
    {
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe);

        alarm.Tell(TempSample(50)); // normal band — no emit
        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));

        alarm.Tell(TempSample(85)); // crosses Hi but not HiHi

        var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Active, msg.State);
        Assert.Equal(AlarmLevel.High, msg.Level);
    }

    [Fact]
    public void AlarmActor_HiLo_EscalatesToHighHigh_WhenValueClimbsPastHiHi()
    {
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe);

        alarm.Tell(TempSample(85));
        var first = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmLevel.High, first.Level);

        alarm.Tell(TempSample(120)); // crosses HiHi

        var second = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Active, second.State);
        Assert.Equal(AlarmLevel.HighHigh, second.Level);
    }

    [Fact]
    public void AlarmActor_HiLo_DescalatesFromHighHighToHigh_WhenValueDrops()
    {
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe);

        alarm.Tell(TempSample(120));
        instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));

        alarm.Tell(TempSample(85)); // back into the Hi band but still alarmed

        var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Active, msg.State);
        Assert.Equal(AlarmLevel.High, msg.Level);
    }

    [Fact]
    public void AlarmActor_HiLo_ClearsToNormal_WhenValueReturnsToNormalBand()
    {
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe);

        alarm.Tell(TempSample(85));
        instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));

        alarm.Tell(TempSample(50)); // back to normal

        var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmState.Normal, clear.State);
        Assert.Equal(AlarmLevel.None, clear.Level);
    }

    [Fact]
    public void AlarmActor_HiLo_EntersLow_WhenValueCrossesLo()
    {
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe);

        alarm.Tell(TempSample(8));

        var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmLevel.Low, msg.Level);
    }

    [Fact]
    public void AlarmActor_HiLo_EntersLowLow_WhenValueCrossesLoLo()
    {
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe);

        alarm.Tell(TempSample(-5));

        var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmLevel.LowLow, msg.Level);
    }

    [Fact]
    public void AlarmActor_HiLo_PerSetpointPriority_OverridesAlarmLevelPriority()
    {
        var instanceProbe = CreateTestProbe();
        // Alarm-level priority is 500; HiHi explicitly bumps to 900.
        const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiPriority"":600,""hiHiPriority"":900}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe, priority: 500);

        alarm.Tell(TempSample(85));
        var hi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmLevel.High, hi.Level);
        Assert.Equal(600, hi.Priority);

        alarm.Tell(TempSample(120));
        var hiHi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmLevel.HighHigh, hiHi.Level);
        Assert.Equal(900, hiHi.Priority);
    }

    [Fact]
    public void AlarmActor_HiLo_MissingPerSetpointPriority_FallsBackToAlarmLevel()
    {
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature"",""hi"":80}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe, priority: 432);

        alarm.Tell(TempSample(85));

        var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(432, msg.Priority);
    }

    [Fact]
    public void AlarmActor_HiLo_PartialConfig_OnlyHiHiSet_NoEffectInLowRange()
    {
        // Only HiHi is configured — values that would have hit a Lo or Hi band
        // (in a fully-configured alarm) are inside the implicit normal band here.
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature"",""hiHi"":100}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe);

        alarm.Tell(TempSample(-1000));
        alarm.Tell(TempSample(95));
        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));

        alarm.Tell(TempSample(110));

        var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmLevel.HighHigh, msg.Level);
    }

    [Fact]
    public void AlarmActor_HiLo_BoundaryValue_AtHiHi_ResolvesToHighHigh()
    {
        // When the value exactly equals the boundary, the most-severe matching
        // band wins. value == HiHi → HighHigh (not High).
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe);

        alarm.Tell(TempSample(100));

        var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmLevel.HighHigh, msg.Level);
    }

    [Fact]
    public void AlarmActor_HiLo_StaysAtSameLevel_NoRedundantEmission()
    {
        // Two updates that resolve to the same level should produce exactly one
        // AlarmStateChanged — the second is a no-op.
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe);

        alarm.Tell(TempSample(85));
        instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));

        alarm.Tell(TempSample(90)); // still in the Hi band

        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
    }

    [Fact]
    public void AlarmActor_HiLo_NoSetpointsConfigured_NeverFires()
    {
        // Validation flags this as a warning at design time; runtime behavior
        // is "evaluates to None forever".
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature""}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe);

        alarm.Tell(TempSample(99999));
        alarm.Tell(TempSample(-99999));

        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
    }

    // ── HiLo hysteresis ────────────────────────────────────────────────────

    [Fact]
    public void AlarmActor_HiLo_Hysteresis_StaysAtHighHigh_UntilDropsBelowDeadband()
    {
        // HiHi=100 with 5-unit deadband. Once at HighHigh, the alarm stays there
        // until the value drops below 95 — at 96 it should still be HighHigh.
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":5}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe);

        alarm.Tell(TempSample(120));
        var entered = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmLevel.HighHigh, entered.Level);

        // 96 > 95 (HiHi - deadband) → still HighHigh, no state change emitted
        alarm.Tell(TempSample(96));
        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
    }

    [Fact]
    public void AlarmActor_HiLo_Hysteresis_DropsToHigh_OnlyAfterDeadbandCleared()
    {
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":5}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe);

        alarm.Tell(TempSample(120));
        instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));

        // 94 < 95 (HiHi - deadband) → drops to High (still above Hi=80)
        alarm.Tell(TempSample(94));
        var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmLevel.High, msg.Level);
    }

    [Fact]
    public void AlarmActor_HiLo_Hysteresis_HiDeadband_PreventsFlapping()
    {
        // Hi=80 with 5-unit deadband. After entering Hi, stays Hi until value drops below 75.
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiDeadband"":5}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe);

        alarm.Tell(TempSample(85));
        instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));

        alarm.Tell(TempSample(78)); // 78 > 75 → still High
        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));

        alarm.Tell(TempSample(74)); // 74 < 75 → clears to Normal
        var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmLevel.None, clear.Level);
    }

    [Fact]
    public void AlarmActor_HiLo_Hysteresis_LowSide_Symmetric()
    {
        // Lo=10 with 3-unit deadband. After entering Lo, stays Lo until value rises above 13.
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10,""loDeadband"":3}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe);

        alarm.Tell(TempSample(8));
        var entered = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmLevel.Low, entered.Level);

        alarm.Tell(TempSample(12)); // 12 <= 13 → still Low
        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));

        alarm.Tell(TempSample(14)); // 14 > 13 → clears
        var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmLevel.None, clear.Level);
    }

    [Fact]
    public void AlarmActor_HiLo_PerBandMessage_FlowsToAlarmStateChanged()
    {
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiMessage"":""Coolant warm — check tank"",""hiHiMessage"":""Coolant critical — shut down""}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe);

        alarm.Tell(TempSample(85));
        var hi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal("Coolant warm — check tank", hi.Message);

        alarm.Tell(TempSample(120));
        var hiHi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal("Coolant critical — shut down", hiHi.Message);

        // Clearing back to normal carries an empty message.
        alarm.Tell(TempSample(50));
        var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(string.Empty, clear.Message);
    }

    [Fact]
    public void AlarmActor_HiLo_Hysteresis_DoesNotDelayEscalation()
    {
        // Deadband is only on de-escalation. Escalating up to HighHigh should not be delayed.
        var instanceProbe = CreateTestProbe();
        const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":50}";
        var alarm = SpawnHiLoAlarm(config, instanceProbe);

        alarm.Tell(TempSample(85));
        instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));

        // Despite the large deadband, escalation uses the activation threshold (100).
        alarm.Tell(TempSample(101));
        var escalated = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(AlarmLevel.HighHigh, escalated.Level);
    }

    // ── M1.5: site event log `alarm` category ──────────────────────────────

    [Fact]
    public void AlarmActor_Raise_EmitsAlarmSiteEvent()
    {
        var siteLog = new FakeSiteEventLogger();
        var sp = new SingleServiceProvider(siteLog);

        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "HighTemp",
            TriggerType = "ValueMatch",
            TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
            PriorityLevel = 800
        };

        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "HighTemp", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance, null, null, null, sp)));

        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
        instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));

        // Background fire-and-forget; allow it to land.
        AwaitAssert(() =>
        {
            var rows = siteLog.OfType("alarm");
            Assert.Single(rows);
            var row = rows[0];
            Assert.Equal("Error", row.Severity); // priority 800 → Error
            Assert.Equal("Pump1", row.InstanceId);
            Assert.Equal("AlarmActor:HighTemp", row.Source);
        }, TimeSpan.FromSeconds(2));
    }

    [Fact]
    public void AlarmActor_RaiseLowPriority_EmitsWarningAlarmSiteEvent()
    {
        var siteLog = new FakeSiteEventLogger();
        var sp = new SingleServiceProvider(siteLog);

        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "MinorTemp",
            TriggerType = "ValueMatch",
            TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Warn\"}",
            PriorityLevel = 100
        };

        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "MinorTemp", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance, null, null, null, sp)));

        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Warn", "Good", DateTimeOffset.UtcNow));
        instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));

        AwaitAssert(() =>
        {
            var rows = siteLog.OfType("alarm");
            Assert.Single(rows);
            Assert.Equal("Warning", rows[0].Severity); // priority 100 → Warning
        }, TimeSpan.FromSeconds(2));
    }

    [Fact]
    public void AlarmActor_Clear_EmitsInfoAlarmSiteEvent()
    {
        var siteLog = new FakeSiteEventLogger();
        var sp = new SingleServiceProvider(siteLog);

        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "HighTemp",
            TriggerType = "ValueMatch",
            TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
            PriorityLevel = 800
        };

        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "HighTemp", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance, null, null, null, sp)));

        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
        instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Normal", "Critical", DateTimeOffset.UtcNow));
        instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));

        AwaitAssert(() =>
        {
            var rows = siteLog.OfType("alarm");
            Assert.Equal(2, rows.Count); // raise + clear
            Assert.Equal("Error", rows[0].Severity);
            Assert.Equal("Info", rows[1].Severity); // clear → Info
        }, TimeSpan.FromSeconds(2));
    }

    [Fact]
    public void AlarmActor_MalformedTriggerConfig_DoesNotCrash()
    {
        // ParseEvalConfig falls back to a safe default on JSON failure; the actor
        // should accept messages without throwing and just never trigger.
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "Bad",
            TriggerType = "ValueMatch",
            TriggerConfiguration = "{not valid json",
            PriorityLevel = 1
        };

        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "Bad", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance)));

        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Anything", "Anything", "anything", "Good", DateTimeOffset.UtcNow));

        instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    // ── P1: Expression-alarm evaluation runs off the dispatcher ──

    /// <summary>
    /// Compiles a trigger expression WITHOUT the trust validator so a test can use
    /// <c>System.Threading.Thread</c> to observe which thread the evaluation runs on.
    /// </summary>
    private static Script<object?> CompileRawTriggerExpression(string expression)
    {
        var opts = ScriptOptions.Default
            .WithReferences(typeof(object).Assembly, typeof(Enumerable).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 ExpressionAlarm_EvaluatesOffTheBlockingScriptPool_AndActivates()
    {
        // WP3.1 retarget of the former P1 assertion, whose sense is deliberately INVERTED.
        //
        // P1 moved the evaluation off the actor's dispatcher and onto the dedicated
        // script-execution scheduler, and this test asserted exactly that. WP3.1 (finding #4)
        // proved that destination wrong for alarms in particular: sharing the blocking pool
        // meant an alarm that should raise in milliseconds queued behind blocked script bodies
        // for an unbounded time, and its 2 s evaluation timeout did not even start ticking
        // until it was dequeued. Evaluations now run on the shared .NET thread pool behind
        // TriggerEvalGate.
        //
        // TRUE only when evaluated OFF a script-execution thread.
        var expr = CompileRawTriggerExpression(
            "System.Threading.Thread.CurrentThread.Name == null || " +
            "!System.Threading.Thread.CurrentThread.Name.StartsWith(\"script-execution-\")");
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "ExprAlarm",
            TriggerType = "Expression",
            TriggerConfiguration = "{\"expression\":\"true\"}",
            PriorityLevel = 1
        };
        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "ExprAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance, expr)));

        alarm.Tell(new AttributeValueChanged("Pump1", "A", "A", 1, "Good", DateTimeOffset.UtcNow));

        var change = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(10));
        Assert.Equal(AlarmState.Active, change.State);
    }

    [Fact]
    public void ExpressionEvalTaskFault_DoesNotKillTheAlarmTrigger()
    {
        var expr = CompileRawTriggerExpression("true");
        var alarmConfig = new ResolvedAlarm
        {
            CanonicalName = "ExprFaultAlarm",
            TriggerType = "Expression",
            TriggerConfiguration = "{\"expression\":\"true\"}",
            PriorityLevel = 1
        };
        var instanceProbe = CreateTestProbe();
        var alarm = ActorOf(Props.Create(() => new AlarmActor(
            "ExprFaultAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
            null, _sharedLibrary, _options,
            NullLogger<AlarmActor>.Instance, expr)));

        // Simulate the faulted scheduler task: must be handled (logged, in-flight
        // cleared, false applied) — NOT an unhandled Status.Failure.
        alarm.Tell(new AlarmActor.ExpressionEvalFailed(new ObjectDisposedException("scheduler")));

        // The trigger must still be alive: the next change evaluates and activates.
        alarm.Tell(new AttributeValueChanged("Pump1", "A", "A", 1, "Good", DateTimeOffset.UtcNow));
        var change = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(10));
        Assert.Equal(AlarmState.Active, change.State);
    }
}
