using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;

namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;

/// <summary>
///     End-to-end engine tests: load, predicate evaluation, change-triggered
///     re-evaluation, state persistence, startup recovery, error isolation.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptedAlarmEngineTests
{
    private static ScriptedAlarmEngine Build(FakeUpstream up, out IAlarmStateStore store)
    {
        store = new InMemoryAlarmStateStore();
        var logger = new LoggerConfiguration().CreateLogger();
        return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
    }

    private static ScriptedAlarmDefinition Alarm(string id, string predicate,
        string msg = "condition", AlarmSeverity sev = AlarmSeverity.High,
        bool historizeToAveva = true) =>
        new(AlarmId: id,
            EquipmentPath: "Plant/Line1/Reactor",
            AlarmName: id,
            Kind: AlarmKind.AlarmCondition,
            Severity: sev,
            MessageTemplate: msg,
            PredicateScriptSource: predicate,
            HistorizeToAveva: historizeToAveva);

    /// <summary>Verifies that LoadAsync compiles the alarm predicate and subscribes to all referenced upstream tags.</summary>
    [Fact]
    public async Task Load_compiles_and_subscribes_to_referenced_upstreams()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out _);

        await eng.LoadAsync([Alarm("a1", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        eng.LoadedAlarmIds.ShouldContain("a1");
        up.ActiveSubscriptionCount.ShouldBe(1);
    }

    /// <summary>Verifies that compile failures across multiple alarms are aggregated into a single error.</summary>
    [Fact]
    public async Task Compile_failures_aggregated_into_one_error()
    {
        var up = new FakeUpstream();
        using var eng = Build(up, out _);

        var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
            await eng.LoadAsync([
                Alarm("bad1", "return unknownIdentifier;"),
                Alarm("good", "return true;"),
                Alarm("bad2", "var x = alsoUnknown; return x;"),
            ], TestContext.Current.CancellationToken));
        ex.Message.ShouldContain("2 alarm(s) did not compile");
    }

    /// <summary>Verifies that an upstream tag change triggers predicate re-evaluation and emits an Activated event.</summary>
    [Fact]
    public async Task Upstream_change_re_evaluates_predicate_and_emits_Activated()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out _);
        await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        var events = new List<ScriptedAlarmEvent>();
        eng.OnEvent += (_, e) => events.Add(e);

        up.Push("Temp", 150);
        await WaitForAsync(() => events.Count > 0);

        events[0].AlarmId.ShouldBe("HighTemp");
        events[0].Emission.ShouldBe(EmissionKind.Activated);
        eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
    }

    /// <summary>Verifies that clearing an upstream tag value emits a Cleared event and transitions the alarm to Inactive.</summary>
    [Fact]
    public async Task Clearing_upstream_emits_Cleared_event()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 150);
        using var eng = Build(up, out _);
        await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        // Startup sees 150 → active.
        eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);

        var events = new List<ScriptedAlarmEvent>();
        eng.OnEvent += (_, e) => events.Add(e);

        up.Push("Temp", 50);
        await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Cleared));
        eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive);
    }

    /// <summary>Verifies that the message template resolves current tag values at the moment of alarm emission.</summary>
    [Fact]
    public async Task Message_template_resolves_tag_values_at_emission()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        up.Set("Limit", 100);
        using var eng = Build(up, out _);
        await eng.LoadAsync([
            new ScriptedAlarmDefinition(
                "HighTemp", "Plant/Line1", "HighTemp",
                AlarmKind.LimitAlarm, AlarmSeverity.High,
                "Temp {Temp}C exceeded limit {Limit}C",
                """return (int)ctx.GetTag("Temp").Value > (int)ctx.GetTag("Limit").Value;"""),
        ], TestContext.Current.CancellationToken);

        var events = new List<ScriptedAlarmEvent>();
        eng.OnEvent += (_, e) => events.Add(e);

        up.Push("Temp", 150);
        await WaitForAsync(() => events.Any());

        events[0].Message.ShouldBe("Temp 150C exceeded limit 100C");
    }

    /// <summary>Verifies that AcknowledgeAsync records the operator user and persists the ack state to the store.</summary>
    [Fact]
    public async Task Ack_records_user_and_persists_to_store()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 150);
        using var eng = Build(up, out var store);
        await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        await eng.AcknowledgeAsync("HighTemp", "alice", "checking", TestContext.Current.CancellationToken);

        var persisted = await store.LoadAsync("HighTemp", TestContext.Current.CancellationToken);
        persisted.ShouldNotBeNull();
        persisted!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
        persisted.LastAckUser.ShouldBe("alice");
        persisted.LastAckComment.ShouldBe("checking");
        persisted.Comments.Any(c => c.Kind == "Acknowledge" && c.User == "alice").ShouldBeTrue();
    }

    /// <summary>Verifies that startup recovery restores the persisted ack state but re-derives the active state from the live predicate.</summary>
    [Fact]
    public async Task Startup_recovery_preserves_ack_but_rederives_active_from_predicate()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50); // predicate will go false on second load

        // First run — alarm goes active + operator acks.
        using (var eng1 = Build(up, out var sharedStore))
        {
            up.Set("Temp", 150);
            await eng1.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
                TestContext.Current.CancellationToken);
            eng1.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);

            await eng1.AcknowledgeAsync("HighTemp", "alice", null, TestContext.Current.CancellationToken);
            eng1.GetState("HighTemp")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
        }

        // Simulate restart — temp is back to 50 (below threshold).
        up.Set("Temp", 50);
        var logger = new LoggerConfiguration().CreateLogger();
        var store2 = new InMemoryAlarmStateStore();
        // seed store2 with the acked state from before restart
        await store2.SaveAsync(new AlarmConditionState(
            "HighTemp",
            AlarmEnabledState.Enabled,
            AlarmActiveState.Active,         // was active pre-restart
            AlarmAckedState.Acknowledged,    // ack persisted
            AlarmConfirmedState.Unconfirmed,
            ShelvingState.Unshelved,
            DateTime.UtcNow,
            DateTime.UtcNow, null,
            DateTime.UtcNow, "alice", null,
            null, null, null,
            [new AlarmComment(DateTime.UtcNow, "alice", "Acknowledge", "")]),
            TestContext.Current.CancellationToken);

        using var eng2 = new ScriptedAlarmEngine(up, store2, new ScriptLoggerFactory(logger), logger);
        await eng2.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        var s = eng2.GetState("HighTemp")!;
        s.Active.ShouldBe(AlarmActiveState.Inactive, "Active recomputed from current tag value");
        s.Acked.ShouldBe(AlarmAckedState.Acknowledged, "Ack persisted across restart");
        s.LastAckUser.ShouldBe("alice");
    }

    /// <summary>Verifies that a shelved alarm transitions its internal state on activation but suppresses the Activated emission.</summary>
    [Fact]
    public async Task Shelved_active_transitions_state_but_suppresses_emission()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out _);
        await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        await eng.OneShotShelveAsync("HighTemp", "alice", TestContext.Current.CancellationToken);

        var events = new List<ScriptedAlarmEvent>();
        eng.OnEvent += (_, e) => events.Add(e);

        up.Push("Temp", 150);
        await Task.Delay(200);

        events.Any(e => e.Emission == EmissionKind.Activated).ShouldBeFalse(
            "OneShot shelve suppresses activation emission");
        eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active,
            "state still advances so startup recovery is consistent");
    }

    /// <summary>Verifies that a runtime exception thrown by a predicate script leaves the alarm state unchanged and does not affect other alarms.</summary>
    [Fact]
    public async Task Predicate_runtime_exception_does_not_transition_state()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 150);
        using var eng = Build(up, out _);
        await eng.LoadAsync([
            Alarm("BadScript", """throw new InvalidOperationException("boom");"""),
            Alarm("GoodScript", """return (int)ctx.GetTag("Temp").Value > 100;"""),
        ], TestContext.Current.CancellationToken);

        // Bad script doesn't activate + doesn't disable other alarms.
        eng.GetState("BadScript")!.Active.ShouldBe(AlarmActiveState.Inactive);
        eng.GetState("GoodScript")!.Active.ShouldBe(AlarmActiveState.Active);
    }

    /// <summary>Verifies that a disabled alarm does not activate on predicate change and resumes normally after being re-enabled.</summary>
    [Fact]
    public async Task Disable_prevents_activation_until_re_enabled()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out _);
        await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        await eng.DisableAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
        up.Push("Temp", 150);
        await Task.Delay(100);
        eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive,
            "disabled alarm ignores predicate");

        await eng.EnableAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
        up.Push("Temp", 160);
        await WaitForAsync(() => eng.GetState("HighTemp")!.Active == AlarmActiveState.Active);
    }

    /// <summary>Verifies that AddCommentAsync appends to the audit trail without changing the alarm's active or ack state.</summary>
    [Fact]
    public async Task AddComment_appends_to_audit_without_state_change()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out var store);
        await eng.LoadAsync([Alarm("A", """return false;""")], TestContext.Current.CancellationToken);

        await eng.AddCommentAsync("A", "alice", "peeking at this", TestContext.Current.CancellationToken);

        var s = await store.LoadAsync("A", TestContext.Current.CancellationToken);
        s.ShouldNotBeNull();
        s!.Comments.Count.ShouldBe(1);
        s.Comments[0].User.ShouldBe("alice");
        s.Comments[0].Kind.ShouldBe("AddComment");
    }

    /// <summary>Verifies that predicate scripts are forbidden from calling SetVirtualTag, and that the exception is isolated without state change.</summary>
    [Fact]
    public async Task Predicate_scripts_cannot_SetVirtualTag()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 100);
        using var eng = Build(up, out _);

        // The script compiles fine but throws at runtime when SetVirtualTag is called.
        // The engine swallows the exception + leaves state unchanged.
        await eng.LoadAsync([
            new ScriptedAlarmDefinition(
                "Bad", "Plant/Line1", "Bad",
                AlarmKind.AlarmCondition, AlarmSeverity.High, "bad",
                """
                ctx.SetVirtualTag("NotAllowed", 1);
                return true;
                """),
        ], TestContext.Current.CancellationToken);

        // Bad alarm's predicate threw — state unchanged.
        eng.GetState("Bad")!.Active.ShouldBe(AlarmActiveState.Inactive);
    }

    /// <summary>Verifies that disposing the engine releases all upstream tag subscriptions.</summary>
    [Fact]
    public async Task Dispose_releases_upstream_subscriptions()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        var eng = Build(up, out _);
        await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);
        up.ActiveSubscriptionCount.ShouldBe(1);

        eng.Dispose();
        up.ActiveSubscriptionCount.ShouldBe(0);
    }

    /// <summary>Verifies that concurrent reads of alarm state during dictionary mutations do not throw (regression for Core.ScriptedAlarms-001).</summary>
    [Fact]
    public async Task Concurrent_reads_during_mutation_do_not_throw(/* Core.ScriptedAlarms-001 */)
    {
        // Regression for Core.ScriptedAlarms-001: GetState / GetAllStates /
        // LoadedAlarmIds touch _alarms from arbitrary threads with no lock while
        // ApplyAsync / ReevaluateAsync reassign dictionary entries under _evalGate.
        // With a plain Dictionary this race throws InvalidOperationException or
        // returns torn state; with a ConcurrentDictionary the reads are safe.
        var up = new FakeUpstream();
        const int alarmCount = 40;
        var defs = new List<ScriptedAlarmDefinition>();
        for (var i = 0; i < alarmCount; i++)
        {
            up.Set($"Temp{i}", 50);
            defs.Add(Alarm($"A{i}", $$"""return (int)ctx.GetTag("Temp{{i}}").Value > 100;"""));
        }

        using var eng = Build(up, out _);
        await eng.LoadAsync(defs, TestContext.Current.CancellationToken);

        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
        Exception? readerFailure = null;

        // Writer: hammer the engine with state mutations that reassign _alarms entries.
        var writer = Task.Run(async () =>
        {
            var round = 0;
            while (!cts.IsCancellationRequested)
            {
                var id = $"A{round % alarmCount}";
                await eng.AddCommentAsync(id, "tester", $"r{round}", CancellationToken.None);
                up.Push($"Temp{round % alarmCount}", round % 2 == 0 ? 150 : 50);
                round++;
            }
        });

        // Readers: continuously touch the three unguarded read paths.
        var readers = Enumerable.Range(0, 4).Select(_reader => Task.Run(() =>
        {
            try
            {
                while (!cts.IsCancellationRequested)
                {
                    _ = eng.LoadedAlarmIds.Count;
                    _ = eng.GetAllStates().Count;
                    for (var i = 0; i < alarmCount; i++)
                        _ = eng.GetState($"A{i}");
                }
            }
            catch (Exception ex)
            {
                readerFailure = ex;
            }
        })).ToArray();

        await Task.WhenAll([writer, .. readers]);

        readerFailure.ShouldBeNull(
            "concurrent reads of _alarms while it is being mutated must not throw");
    }

    // -------------------------------------------------------------------------
    // Core.ScriptedAlarms-012: coverage gaps
    // -------------------------------------------------------------------------

    // (1) Timed-shelve auto-expiry driven by the engine's shelving timer via an
    //     injectable clock — the clock and scriptTimeout constructor parameters
    //     exist for exactly this.
    /// <summary>Verifies that a timed shelve automatically expires when the engine's shelving check runs past the unshelve time.</summary>
    [Fact]
    public async Task TimedShelve_auto_expires_when_engine_shelving_check_runs(/* -012 (1) */)
    {
        // Use a controllable clock; start it at T0 so we can advance it manually.
        var now = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
        Func<DateTime> clock = () => now;

        var up = new FakeUpstream();
        up.Set("Temp", 50);
        var logger = new LoggerConfiguration().CreateLogger();
        var store = new InMemoryAlarmStateStore();
        using var eng = new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger, clock);

        await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        // Shelve for 10 minutes from now.
        var unshelveAt = now.AddMinutes(10);
        await eng.TimedShelveAsync("HighTemp", "alice", unshelveAt, TestContext.Current.CancellationToken);
        eng.GetState("HighTemp")!.Shelving.Kind.ShouldBe(ShelvingKind.Timed);

        var events = new List<ScriptedAlarmEvent>();
        eng.OnEvent += (_, e) => events.Add(e);

        // Advance clock past the unshelve time and invoke RunShelvingCheck directly
        // (the 5-second real timer would be non-deterministic in tests).
        now = now.AddMinutes(11);
        eng.RunShelvingCheckForTest();

        await WaitForAsync(() => eng.GetState("HighTemp")!.Shelving.Kind == ShelvingKind.Unshelved);

        eng.GetState("HighTemp")!.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved, "timed shelve expired");
        events.Any(e => e.Emission == EmissionKind.Unshelved).ShouldBeTrue("Unshelved event emitted");
    }

    // (2a) ConfirmAsync end-to-end through the engine.
    /// <summary>Verifies that ConfirmAsync records the confirming user and emits a Confirmed event persisted to the store.</summary>
    [Fact]
    public async Task ConfirmAsync_records_user_and_emits_Confirmed(/* -012 (2) */)
    {
        var up = new FakeUpstream();
        up.Set("Temp", 150);
        using var eng = Build(up, out var store);
        await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        // activate → ack → clear → confirm
        await eng.AcknowledgeAsync("A", "alice", null, TestContext.Current.CancellationToken);
        up.Push("Temp", 50);
        await WaitForAsync(() => eng.GetState("A")!.Active == AlarmActiveState.Inactive);

        var events = new List<ScriptedAlarmEvent>();
        eng.OnEvent += (_, e) => events.Add(e);
        await eng.ConfirmAsync("A", "bob", "resolved", TestContext.Current.CancellationToken);

        eng.GetState("A")!.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
        eng.GetState("A")!.LastConfirmUser.ShouldBe("bob");
        events.Any(e => e.Emission == EmissionKind.Confirmed).ShouldBeTrue();
        var persisted = await store.LoadAsync("A", TestContext.Current.CancellationToken);
        persisted!.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
    }

    /// <summary>Verifies the emitted <see cref="ScriptedAlarmEvent.Comment"/> carries the operator's
    /// ack comment on the Acknowledged emission while the earlier Activated emission has no comment,
    /// and that an explicit AddComment emission carries the comment text. (historian feed prep)</summary>
    [Fact]
    public async Task Emission_carries_operator_Comment_on_ack_and_add_comment()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out _);
        await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        var events = new List<ScriptedAlarmEvent>();
        eng.OnEvent += (_, e) => events.Add(e);

        // Activate → Acknowledge(comment) → AddComment(text).
        up.Push("Temp", 150);
        await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Activated));
        await eng.AcknowledgeAsync("HighTemp", "op1", "looking into it", TestContext.Current.CancellationToken);
        await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Acknowledged));
        await eng.AddCommentAsync("HighTemp", "op2", "second look", TestContext.Current.CancellationToken);
        await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.CommentAdded));

        // Engine-driven Activated emission carries no operator comment.
        events.First(e => e.Emission == EmissionKind.Activated).Comment.ShouldBeNull();
        // Acknowledged emission carries the ack comment.
        events.First(e => e.Emission == EmissionKind.Acknowledged).Comment.ShouldBe("looking into it");
        // CommentAdded emission carries the latest appended comment text.
        events.First(e => e.Emission == EmissionKind.CommentAdded).Comment.ShouldBe("second look");
    }

    /// <summary>Verifies the emitted <see cref="ScriptedAlarmEvent.HistorizeToAveva"/> carries the
    /// per-alarm opt-out flag from the <see cref="ScriptedAlarmDefinition"/> through to the event so the
    /// historian adapter can suppress the durable write while the live alerts fan-out is unaffected.
    /// An alarm defined <c>HistorizeToAveva: false</c> emits <c>false</c>; the default (<c>true</c>)
    /// emits <c>true</c>.</summary>
    [Fact]
    public async Task Emission_carries_HistorizeToAveva_flag_from_definition()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out _);
        await eng.LoadAsync(
            [
                Alarm("OptOut", """return (int)ctx.GetTag("Temp").Value > 100;""", historizeToAveva: false),
                Alarm("OptIn", """return (int)ctx.GetTag("Temp").Value > 100;""" /* default true */),
            ],
            TestContext.Current.CancellationToken);

        var events = new List<ScriptedAlarmEvent>();
        eng.OnEvent += (_, e) => events.Add(e);

        up.Push("Temp", 150);
        await WaitForAsync(() =>
            events.Any(e => e.AlarmId == "OptOut" && e.Emission == EmissionKind.Activated) &&
            events.Any(e => e.AlarmId == "OptIn" && e.Emission == EmissionKind.Activated));

        events.First(e => e.AlarmId == "OptOut" && e.Emission == EmissionKind.Activated)
            .HistorizeToAveva.ShouldBeFalse("opt-out alarm carries HistorizeToAveva=false on its emission");
        events.First(e => e.AlarmId == "OptIn" && e.Emission == EmissionKind.Activated)
            .HistorizeToAveva.ShouldBeTrue("opt-in (default) alarm carries HistorizeToAveva=true on its emission");
    }

    // (2b) TimedShelveAsync / UnshelveAsync end-to-end through the engine.
    /// <summary>Verifies that TimedShelveAsync shelves with a deadline and UnshelveAsync removes the shelve before the timer expires.</summary>
    [Fact]
    public async Task TimedShelveAsync_and_UnshelveAsync_round_trip(/* -012 (2) */)
    {
        var now = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
        Func<DateTime> clock = () => now;

        var up = new FakeUpstream();
        up.Set("Temp", 50);
        var logger = new LoggerConfiguration().CreateLogger();
        using var eng = new ScriptedAlarmEngine(up, new InMemoryAlarmStateStore(),
            new ScriptLoggerFactory(logger), logger, clock);
        await eng.LoadAsync([Alarm("A", "return false;")], TestContext.Current.CancellationToken);

        var unshelveAt = now.AddMinutes(30);
        await eng.TimedShelveAsync("A", "alice", unshelveAt, TestContext.Current.CancellationToken);
        eng.GetState("A")!.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
        eng.GetState("A")!.Shelving.UnshelveAtUtc.ShouldBe(unshelveAt);

        // Operator unshelves manually before the timer expires.
        var events = new List<ScriptedAlarmEvent>();
        eng.OnEvent += (_, e) => events.Add(e);
        await eng.UnshelveAsync("A", "bob", TestContext.Current.CancellationToken);
        eng.GetState("A")!.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
        events.Any(e => e.Emission == EmissionKind.Unshelved).ShouldBeTrue();
    }

    // (2c) EnableAsync end-to-end through the engine.
    /// <summary>Verifies that EnableAsync transitions the alarm back to Enabled state and emits an Enabled event.</summary>
    [Fact]
    public async Task EnableAsync_re_enables_after_disable(/* -012 (2) */)
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out _);
        await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        await eng.DisableAsync("A", "alice", TestContext.Current.CancellationToken);
        eng.GetState("A")!.Enabled.ShouldBe(AlarmEnabledState.Disabled);

        var events = new List<ScriptedAlarmEvent>();
        eng.OnEvent += (_, e) => events.Add(e);
        await eng.EnableAsync("A", "bob", TestContext.Current.CancellationToken);
        eng.GetState("A")!.Enabled.ShouldBe(AlarmEnabledState.Enabled);
        events.Any(e => e.Emission == EmissionKind.Enabled).ShouldBeTrue();
    }

    // (3) OnEvent subscriber-throws isolation — a bad subscriber must not crash
    //     the engine or prevent subsequent alarm state transitions. The engine logs
    //     the exception and continues operating; any later alarm changes still work.
    /// <summary>Verifies that an exception thrown by an OnEvent subscriber is isolated and does not crash the engine or prevent further state transitions.</summary>
    [Fact]
    public async Task OnEvent_subscriber_exception_does_not_crash_engine(/* -012 (3) */)
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out _);
        await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        // Single subscriber that always throws.
        eng.OnEvent += (_, _) => throw new InvalidOperationException("bad subscriber");

        // The engine must not throw or get stuck when the subscriber throws.
        // Up.Push triggers ReevaluateAsync → EvaluatePredicateToStateAsync → EmitEvent.
        up.Push("Temp", 150);

        // Wait for the engine to process the push (it will try+catch the subscriber
        // throw and continue). State must advance even though the subscriber blew up.
        await WaitForAsync(() => eng.GetState("A")!.Active == AlarmActiveState.Active);

        eng.GetState("A")!.Active.ShouldBe(AlarmActiveState.Active,
            "engine state advances even when the OnEvent subscriber threw");

        // Verify the engine is still operational: a second state change must work.
        up.Push("Temp", 50);
        await WaitForAsync(() => eng.GetState("A")!.Active == AlarmActiveState.Inactive);
        eng.GetState("A")!.Active.ShouldBe(AlarmActiveState.Inactive,
            "engine keeps processing after subscriber exception");
    }

    // (4) IAlarmStateStore.SaveAsync failure — in-memory state must remain at the
    //     prior value after finding -007 fix (persist-before-update).
    /// <summary>Verifies that a store SaveAsync failure leaves the in-memory alarm state at its prior value (persist-before-update invariant, finding -007).</summary>
    [Fact]
    public async Task Store_save_failure_leaves_in_memory_state_unchanged(/* -012 (4) */)
    {
        var up = new FakeUpstream();
        up.Set("Temp", 150);
        var logger = new LoggerConfiguration().CreateLogger();
        var failingStore = new FailOnSaveAlarmStateStore();
        using var eng = new ScriptedAlarmEngine(up, failingStore, new ScriptLoggerFactory(logger), logger);

        // LoadAsync seeds + persists startup state — make it succeed for now.
        failingStore.FailSave = false;
        await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);
        // Startup evaluation activated the alarm.
        eng.GetState("A")!.Active.ShouldBe(AlarmActiveState.Active);

        // Now make every save throw.
        failingStore.FailSave = true;

        // Try to acknowledge — the save will fail, so the in-memory ack state must
        // remain Unacknowledged (persist-before-update: -007 fix).
        await Should.ThrowAsync<InvalidOperationException>(
            () => eng.AcknowledgeAsync("A", "alice", null, TestContext.Current.CancellationToken));

        eng.GetState("A")!.Acked.ShouldBe(AlarmAckedState.Unacknowledged,
            "in-memory state must stay at prior value when store save fails");
    }

    // (5) Re-entrant LoadAsync — the old timer must not keep firing after a second
    //     call (regression for finding -002: _shelvingTimer?.Dispose() fix).
    /// <summary>Verifies that a second LoadAsync call disposes the prior shelving timer so it does not keep firing after reload (regression for finding -002).</summary>
    [Fact]
    public async Task Second_LoadAsync_does_not_leak_old_timer(/* -012 (5) */)
    {
        // Use a clock whose invocation count we can observe indirectly through
        // shelving-check side effects: if the old timer leaked it would call
        // RunShelvingCheck an additional time, but we assert no double-disposal
        // or double-subscriptions instead.
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out _);

        await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);
        var subsAfterFirst = up.ActiveSubscriptionCount;

        // Second load with a different alarm definition set.
        await eng.LoadAsync([Alarm("B", """return (int)ctx.GetTag("Temp").Value > 200;""")],
            TestContext.Current.CancellationToken);

        // After reload, only "B" should be present; "A" subscriptions released.
        eng.LoadedAlarmIds.ShouldContain("B");
        eng.LoadedAlarmIds.ShouldNotContain("A");
        // Subscriptions should match the new set only (one path "Temp" → 1 sub).
        up.ActiveSubscriptionCount.ShouldBe(subsAfterFirst, "subscription count same after reload on same path");

        // Engine is still functional.
        up.Push("Temp", 250);
        await WaitForAsync(() => eng.GetState("B")!.Active == AlarmActiveState.Active);
    }

    // (6) Cold-start AreInputsReady guard — null value, Bad status, and Uncertain
    //     status inputs are all handled correctly.
    /// <summary>Verifies that AreInputsReady blocks predicate evaluation when inputs have null values or Bad status codes, while Uncertain quality is accepted.</summary>
    [Fact]
    public async Task AreInputsReady_blocks_evaluation_for_null_and_bad_inputs(/* -012 (6) */)
    {
        var up = new FakeUpstream();
        // "Temp" is missing entirely (ReadTag returns BadNodeIdUnknown).
        using var eng = Build(up, out _);

        await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        // No tag value → bad status → AreInputsReady returns false → stays Inactive.
        eng.GetState("A")!.Active.ShouldBe(AlarmActiveState.Inactive,
            "predicate not evaluated when input has Bad status");

        // Now provide a bad-quality value explicitly.
        up.Set("Temp", 150, statusCode: 0x80000000u); // Bad severity bit
        up.Push("Temp", 150, statusCode: 0x80000000u);
        await Task.Delay(100, TestContext.Current.CancellationToken);
        eng.GetState("A")!.Active.ShouldBe(AlarmActiveState.Inactive,
            "predicate not evaluated when input has explicit Bad status code");

        // Uncertain quality (non-zero but bit 31 clear) — should be treated as ready.
        // The alarm should activate when the value is above 100 with Uncertain quality.
        up.Set("Temp", 150, statusCode: 0x40000000u); // Uncertain severity
        up.Push("Temp", 150, statusCode: 0x40000000u);
        await WaitForAsync(() => eng.GetState("A")!.Active == AlarmActiveState.Active);
        eng.GetState("A")!.Active.ShouldBe(AlarmActiveState.Active,
            "Uncertain-quality inputs are treated as ready — predicate evaluates");
    }

    // -------------------------------------------------------------------------
    // Core.ScriptedAlarms-003: OnEvent emission must not block under _evalGate.
    // (1) A slow subscriber must not block the gate for other alarms.
    // (2) A subscriber that re-enters the engine (e.g. AcknowledgeAsync) must
    //     not deadlock against _evalGate. Both regressions are covered here.
    // -------------------------------------------------------------------------
    /// <summary>Verifies that an OnEvent subscriber can call engine methods (e.g. AcknowledgeAsync) without deadlocking against the evaluation gate (regression for Core.ScriptedAlarms-003).</summary>
    [Fact]
    public async Task OnEvent_subscriber_can_call_back_into_engine_without_deadlock(/* -003 */)
    {
        // Re-entrancy regression. When OnEvent emission was inside _evalGate, a
        // subscriber that called an engine method (e.g. AcknowledgeAsync) hung
        // forever because the non-reentrant SemaphoreSlim refused to re-grant
        // the gate the dispatch path was still holding. After the fix, emission
        // happens AFTER Release() so the subscriber's call acquires the gate
        // cleanly and the operator-driven action completes.
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        var eng = Build(up, out _);
        try
        {
            await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
                TestContext.Current.CancellationToken);

            // Subscriber re-enters the engine via Task.Run so the OnEvent
            // dispatch thread is not blocked while waiting. Either way, with
            // the fix in place AcknowledgeAsync must acquire _evalGate (the
            // dispatch path released it before invoking the subscriber) and
            // complete in well under the timeout.
            var ackDone = new TaskCompletionSource();
            eng.OnEvent += (_, e) =>
            {
                if (e.Emission != EmissionKind.Activated) return;
                _ = Task.Run(async () =>
                {
                    try
                    {
                        await eng.AcknowledgeAsync(e.AlarmId, "sub", null, CancellationToken.None);
                        ackDone.TrySetResult();
                    }
                    catch (Exception ex) { ackDone.TrySetException(ex); }
                });
            };

            up.Push("Temp", 150);

            var winner = await Task.WhenAny(ackDone.Task, Task.Delay(TimeSpan.FromSeconds(3)));
            winner.ShouldBe(ackDone.Task,
                "subscriber re-entering the engine must not deadlock against _evalGate");
            await ackDone.Task; // surface any inner exception
            eng.GetState("HighTemp")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
        }
        finally
        {
            eng.Dispose();
        }
    }

    /// <summary>Verifies that OnEvent emission occurs after the evaluation gate is released, so subscribers can re-enter the engine without deadlocking (regression for Core.ScriptedAlarms-003).</summary>
    [Fact]
    public void OnEvent_emission_happens_outside_evalGate(/* -003 */)
    {
        // Direct white-box check on the gate-release ordering: AcknowledgeAsync
        // emits the Acknowledged event AFTER releasing the gate. We assert that
        // by observing the gate is acquirable from inside the subscriber.
        // SemaphoreSlim.Wait(0) returns true only if the count > 0 (gate free).
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        var eng = Build(up, out _);
        try
        {
            eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
                TestContext.Current.CancellationToken).GetAwaiter().GetResult();
            // Drive to Active so Acknowledge has something to ack.
            up.Push("Temp", 150);
            // Use the same WaitForAsync that other tests use — synchronously
            // here since this is a non-async test.
            for (var i = 0; i < 80 && eng.GetState("HighTemp")!.Active != AlarmActiveState.Active; i++)
                Thread.Sleep(25);
            eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);

            // Use reflection to peek at _evalGate so the subscriber can probe it.
            var gateField = typeof(ScriptedAlarmEngine).GetField(
                "_evalGate", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
            gateField.ShouldNotBeNull();
            var gate = (SemaphoreSlim)gateField.GetValue(eng)!;

            var gateFreeInsideEmission = false;
            eng.OnEvent += (_, e) =>
            {
                if (e.Emission != EmissionKind.Acknowledged) return;
                // SemaphoreSlim.Wait(0) — non-blocking try-take. If the gate is
                // free we acquire it (count back to 0); release immediately.
                if (gate.Wait(0))
                {
                    gateFreeInsideEmission = true;
                    gate.Release();
                }
            };

            eng.AcknowledgeAsync("HighTemp", "alice", null, CancellationToken.None)
               .GetAwaiter().GetResult();

            gateFreeInsideEmission.ShouldBeTrue(
                "_evalGate must be released before OnEvent fires so subscribers " +
                "can call back into the engine without deadlocking");
        }
        finally
        {
            eng.Dispose();
        }
    }

    // -------------------------------------------------------------------------
    // Core.ScriptedAlarms-006: Dispose must drain in-flight background tasks
    // launched by OnUpstreamChange / RunShelvingCheck. Otherwise a re-evaluation
    // or shelving check started just before Dispose can keep running and write
    // to a (possibly disposed) store after the engine has returned.
    // -------------------------------------------------------------------------
    /// <summary>Verifies that Dispose blocks until in-flight background re-evaluation tasks complete, preventing the engine from outliving its store (regression for Core.ScriptedAlarms-006).</summary>
    [Fact]
    public async Task Dispose_drains_in_flight_reevaluation_tasks(/* -006 */)
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        var logger = new LoggerConfiguration().CreateLogger();
        var slowStore = new BlockingSaveAlarmStateStore();
        var eng = new ScriptedAlarmEngine(up, slowStore, new ScriptLoggerFactory(logger), logger);
        await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        // Block the NEXT save (the one triggered by the push below).
        var saveGate = new TaskCompletionSource();
        slowStore.BlockNextSave = saveGate;

        // Trigger a re-evaluation that will go inside _evalGate and call SaveAsync.
        up.Push("Temp", 150);

        // Wait until the store's SaveAsync is actually blocked.
        await WaitForAsync(() => slowStore.SaveInProgress, timeoutMs: 1000);

        // Dispose must wait for the in-flight reevaluation to complete rather
        // than returning while a background task still runs.
        var disposeTask = Task.Run(() => eng.Dispose());

        // Verify Dispose does NOT complete immediately — it should block waiting
        // for the in-flight task. Without the -006 fix Dispose returns straight
        // away and the background reevaluation can outlive the engine.
        var prematureFinish = await Task.WhenAny(disposeTask, Task.Delay(200));
        prematureFinish.ShouldNotBe(disposeTask,
            "Dispose must block until in-flight background tasks complete");

        // Let the save complete and verify Dispose then returns.
        saveGate.SetResult();
        await disposeTask.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
        slowStore.SaveInProgress.ShouldBeFalse("background task drained before Dispose returned");
    }

    // -------------------------------------------------------------------------
    // Core.ScriptedAlarms-010: predicate evaluation and message-template
    // resolution apply different quality bars on purpose. Predicate evaluation
    // accepts Uncertain (the predicate can still inspect the value); message
    // resolution renders Uncertain as "{?}" so the operator sees the doubt
    // explicitly. The two policies are documented in docs/ScriptedAlarms.md.
    // -------------------------------------------------------------------------
    /// <summary>Verifies that Uncertain-quality inputs are accepted by the predicate but rendered as "{?}" in the operator-facing message template (Core.ScriptedAlarms-010).</summary>
    [Fact]
    public async Task Uncertain_quality_drives_predicate_but_renders_question_mark_in_message(/* -010 */)
    {
        var up = new FakeUpstream();
        // Seed with Uncertain quality (severity bit 30 set, bit 31 clear).
        up.Set("Temp", 150, statusCode: 0x40000000u);
        using var eng = Build(up, out _);
        await eng.LoadAsync([
            new ScriptedAlarmDefinition(
                "HighTemp", "Plant/Line1", "HighTemp",
                AlarmKind.LimitAlarm, AlarmSeverity.High,
                "Temp {Temp} exceeded limit",
                """return (int)ctx.GetTag("Temp").Value > 100;"""),
        ], TestContext.Current.CancellationToken);

        // Predicate evaluated (Uncertain treated as ready) → alarm Active.
        eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active,
            "AreInputsReady accepts Uncertain so the predicate runs");

        // But the resolved emission message must show "{?}" for the Uncertain
        // tag — only Good substitutes into the operator-facing message.
        var events = new List<ScriptedAlarmEvent>();
        eng.OnEvent += (_, e) => events.Add(e);
        up.Push("Temp", 200, statusCode: 0x40000000u); // still Uncertain
        // Trigger another evaluation to get an emission (already active, so
        // we need a clear → re-activate cycle). Easier: force the same path
        // through a comment which emits a CommentAdded message. But comments
        // don't run the template. Instead clear it then re-activate.
        up.Push("Temp", 50, statusCode: 0u); // Good, predicate becomes false
        await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Cleared));
        events.Clear();
        up.Push("Temp", 200, statusCode: 0x40000000u); // Uncertain, predicate true
        await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Activated));

        // The Activated message must show {?} for the Uncertain input.
        events.Single(e => e.Emission == EmissionKind.Activated).Message
            .ShouldBe("Temp {?} exceeded limit",
                "MessageTemplate.Resolve renders non-Good StatusCode as {?} " +
                "even though predicate evaluation accepted the Uncertain value");
    }

    // -------------------------------------------------------------------------
    // Core.ScriptedAlarms-008: switch Comments to ImmutableList for O(log n)
    // append. The persisted runtime type must be ImmutableList<AlarmComment>
    // (which still satisfies IReadOnlyList<AlarmComment> for existing
    // consumers).
    // -------------------------------------------------------------------------
    /// <summary>Verifies that the Comments collection is an ImmutableList, enabling O(log n) append and satisfying IReadOnlyList consumers (Core.ScriptedAlarms-008).</summary>
    [Fact]
    public async Task Comments_collection_uses_ImmutableList_for_efficient_append(/* -008 */)
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out _);
        await eng.LoadAsync([Alarm("A", "return false;")], TestContext.Current.CancellationToken);

        // Add a comment so AppendComment runs.
        await eng.AddCommentAsync("A", "alice", "note", TestContext.Current.CancellationToken);

        var s = eng.GetState("A")!;
        s.Comments.ShouldBeOfType<System.Collections.Immutable.ImmutableList<AlarmComment>>(
            "Comments should be an ImmutableList so append is O(log n), not O(n)");
    }

    // -------------------------------------------------------------------------
    // Core.ScriptedAlarms-011: TransitionResult.NoOp's reason parameter must be
    // propagated, not silently discarded. The class-level remarks promise a
    // diagnostic log line for no-op disabled-alarm evaluations.
    // -------------------------------------------------------------------------
    /// <summary>Verifies that TransitionResult.NoOp preserves the supplied diagnostic reason string for caller logging (Core.ScriptedAlarms-011).</summary>
    [Fact]
    public void TransitionResult_NoOp_propagates_reason(/* -011 */)
    {
        var fresh = AlarmConditionState.Fresh("a-1", DateTime.UtcNow);
        var r = TransitionResult.NoOp(fresh, "disabled — predicate result ignored");
        r.NoOpReason.ShouldBe("disabled — predicate result ignored",
            "NoOp reason must be preserved on the TransitionResult so callers can log it");
    }

    /// <summary>Verifies that TransitionResult.None carries a null reason, distinguishing it from the NoOp factory (Core.ScriptedAlarms-011).</summary>
    [Fact]
    public void TransitionResult_None_carries_no_reason(/* -011 */)
    {
        var fresh = AlarmConditionState.Fresh("a-1", DateTime.UtcNow);
        var r = TransitionResult.None(fresh);
        r.NoOpReason.ShouldBeNull("None() factory has no reason — only NoOp() carries one");
    }

    // -------------------------------------------------------------------------
    // Core.ScriptedAlarms-015: timer runs against partially-cleared _alarms on
    // a failed LoadAsync reload. When a second LoadAsync throws at compile time,
    // _shelvingTimer?.Dispose() (which lives AFTER the compileFailures throw in
    // the pre-fix code) is skipped, so the old timer keeps firing against
    // whatever _alarms was left in after the partial clear + partial recompile.
    // The timer is eventually cleaned up by Dispose(), so there is no permanent
    // resource leak; the observable risk is unexpected shelving-check side effects
    // during the window between the failed reload and Dispose. The fix moves
    // _shelvingTimer?.Dispose() to the START of the try block, alongside
    // UnsubscribeFromUpstream, so the timer is always stopped before _alarms is
    // cleared.
    // -------------------------------------------------------------------------

    /// <summary>Verifies that a failed second LoadAsync (compile errors) leaves the engine in a consistent
    /// state: the old timer is eventually cleaned up by Dispose, a third successful LoadAsync restores
    /// functionality, and no background task outlives the engine (Core.ScriptedAlarms-015).</summary>
    [Fact]
    public async Task Failed_reload_leaves_engine_recoverable_and_disposes_cleanly(/* -015 */)
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        var logger = new LoggerConfiguration().CreateLogger();
        var store = new InMemoryAlarmStateStore();
        var eng = new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);

        // === First load: succeeds ===
        await eng.LoadAsync([Alarm("A", "return false;")], TestContext.Current.CancellationToken);
        eng.LoadedAlarmIds.ShouldContain("A");

        // === Second load: fails at compile time ===
        // The old timer (started by the first load) now runs against whatever _alarms
        // contains after the partial clear + partial recompile. _shelvingTimer?.Dispose()
        // is skipped because it lives after the compileFailures throw.
        var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
            await eng.LoadAsync([Alarm("B", "return cantCompileThis___!;")],
                TestContext.Current.CancellationToken));
        ex.Message.ShouldContain("did not compile");

        // Engine is in a partially-cleared state — A is gone, B didn't compile.
        eng.LoadedAlarmIds.ShouldNotContain("A");
        eng.LoadedAlarmIds.ShouldNotContain("B");

        // === Third load: succeeds — engine is still operational ===
        up.Set("Temp", 50);
        await eng.LoadAsync([Alarm("C", "return false;")], TestContext.Current.CancellationToken);
        eng.LoadedAlarmIds.ShouldContain("C");

        // Dispose must complete cleanly (no background tasks outlive the engine,
        // _shelvingTimer is stopped). Before the fix, the old timer from the first
        // load could keep firing between the failed second load and Dispose.
        // After the fix, it is stopped at the START of every LoadAsync attempt
        // (successful or not), so Dispose always finds a clean timer state.
        var disposeTask = Task.Run(() => eng.Dispose());
        await disposeTask.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
        // If Dispose threw or hung, the WaitAsync would surface it.
    }

    // -------------------------------------------------------------------------
    // #478 — scripted condition Quality from worst-of-input tag quality (Layer 3)
    // -------------------------------------------------------------------------

    private const uint StatusUncertain = 0x40000000u;
    private const uint StatusBad = 0x80000000u;

    /// <summary>A transition that fires while an input is Uncertain carries that worst input quality on the
    /// emitted event (so the projected snapshot doesn't clobber quality back to Good).</summary>
    [Fact]
    public async Task Transition_carries_worst_input_quality()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out _);
        await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        var events = new List<ScriptedAlarmEvent>();
        eng.OnEvent += (_, e) => events.Add(e);

        // Uncertain is "ready" (only outright Bad short-circuits), so the predicate runs and fires Activated.
        up.Push("Temp", 150, StatusUncertain);
        await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Activated));

        var activated = events.First(e => e.Emission == EmissionKind.Activated);
        (activated.WorstInputStatusCode >> 30).ShouldBe(1u); // Uncertain severity bucket
    }

    /// <summary>A Bad input freezes the condition (no transition) but the worst-quality bucket changed
    /// Good→Bad, so the engine emits a standalone QualityChanged carrying Bad.</summary>
    [Fact]
    public async Task Bad_input_without_transition_emits_QualityChanged_Bad()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50); // predicate false → inactive
        using var eng = Build(up, out _);
        await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        var events = new List<ScriptedAlarmEvent>();
        eng.OnEvent += (_, e) => events.Add(e);

        up.Push("Temp", 50, StatusBad);
        await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));

        var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
        (q.WorstInputStatusCode >> 30).ShouldBeGreaterThanOrEqualTo(2u); // Bad severity bucket
        // No phantom state transition accompanied the quality change.
        events.Any(e => e.Emission == EmissionKind.Activated || e.Emission == EmissionKind.Cleared)
            .ShouldBeFalse();
        eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive);
    }

    /// <summary>Restoring a Good input after a Bad one flips the bucket back and emits QualityChanged(Good).</summary>
    [Fact]
    public async Task Restoring_good_input_emits_QualityChanged_Good()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out _);
        await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        var events = new List<ScriptedAlarmEvent>();
        eng.OnEvent += (_, e) => events.Add(e);

        up.Push("Temp", 50, StatusBad);
        await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged
            && (e.WorstInputStatusCode >> 30) >= 2u));
        events.Clear();

        up.Push("Temp", 50, 0u); // Good again, still below threshold
        await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));

        var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
        (q.WorstInputStatusCode >> 30).ShouldBe(0u); // Good bucket
    }

    /// <summary>A value change that keeps the same quality bucket (Good→Good) emits no QualityChanged.</summary>
    [Fact]
    public async Task No_QualityChanged_when_bucket_unchanged()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out _);
        await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        var events = new List<ScriptedAlarmEvent>();
        eng.OnEvent += (_, e) => events.Add(e);

        up.Push("Temp", 60, 0u); // Good→Good, still below threshold → no transition, no quality change
        await Task.Delay(150, TestContext.Current.CancellationToken);

        events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged);
    }

    private static async Task WaitForAsync(Func<bool> cond, int timeoutMs = 2000)
    {
        var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
        while (DateTime.UtcNow < deadline)
        {
            if (cond()) return;
            await Task.Delay(25);
        }
        throw new TimeoutException("Condition did not become true in time");
    }

    // -------------------------------------------------------------------------
    // Test helpers
    // -------------------------------------------------------------------------

    /// <summary>
    ///     A store that can be instructed to throw on every SaveAsync call.
    ///     Used to exercise the persist-before-update invariant (finding -007).
    /// </summary>
    private sealed class FailOnSaveAlarmStateStore : IAlarmStateStore
    {
        private readonly InMemoryAlarmStateStore _inner = new();
        /// <summary>Gets or sets a value indicating whether the next SaveAsync call should throw a simulated failure.</summary>
        public bool FailSave { get; set; }

        /// <summary>Loads an alarm condition state by ID from the inner store.</summary>
        /// <param name="alarmId">The ID of the alarm condition state to load.</param>
        /// <param name="ct">A cancellation token.</param>
        public Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
            => _inner.LoadAsync(alarmId, ct);

        /// <summary>Loads all alarm condition states from the inner store.</summary>
        /// <param name="ct">A cancellation token.</param>
        public Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
            => _inner.LoadAllAsync(ct);

        /// <summary>Saves an alarm condition state, optionally throwing if FailSave is set.</summary>
        /// <param name="state">The alarm condition state to save.</param>
        /// <param name="ct">A cancellation token.</param>
        public Task SaveAsync(AlarmConditionState state, CancellationToken ct)
        {
            if (FailSave) throw new InvalidOperationException("Simulated store failure");
            return _inner.SaveAsync(state, ct);
        }

        /// <summary>Removes an alarm condition state by ID from the inner store.</summary>
        /// <param name="alarmId">The ID of the alarm condition state to remove.</param>
        /// <param name="ct">A cancellation token.</param>
        public Task RemoveAsync(string alarmId, CancellationToken ct)
            => _inner.RemoveAsync(alarmId, ct);
    }

    /// <summary>
    ///     A store whose SaveAsync can be made to block until the test signals it.
    ///     Used to verify Dispose drains in-flight background tasks (finding -006).
    /// </summary>
    private sealed class BlockingSaveAlarmStateStore : IAlarmStateStore
    {
        private readonly InMemoryAlarmStateStore _inner = new();
        /// <summary>Gets or sets a TaskCompletionSource that, when set, blocks the next SaveAsync until signalled.</summary>
        public TaskCompletionSource? BlockNextSave { get; set; }
        /// <summary>Gets a value indicating whether a SaveAsync call is currently blocked waiting on BlockNextSave.</summary>
        public bool SaveInProgress { get; private set; }

        /// <summary>Loads an alarm condition state by ID from the inner store.</summary>
        /// <param name="alarmId">The ID of the alarm condition state to load.</param>
        /// <param name="ct">A cancellation token.</param>
        public Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
            => _inner.LoadAsync(alarmId, ct);

        /// <summary>Loads all alarm condition states from the inner store.</summary>
        /// <param name="ct">A cancellation token.</param>
        public Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
            => _inner.LoadAllAsync(ct);

        /// <summary>Saves an alarm condition state, optionally blocking on BlockNextSave gate.</summary>
        /// <param name="state">The alarm condition state to save.</param>
        /// <param name="ct">A cancellation token.</param>
        public async Task SaveAsync(AlarmConditionState state, CancellationToken ct)
        {
            var gate = BlockNextSave;
            if (gate is not null)
            {
                BlockNextSave = null;
                SaveInProgress = true;
                try { await gate.Task.WaitAsync(ct).ConfigureAwait(false); }
                finally { SaveInProgress = false; }
            }
            await _inner.SaveAsync(state, ct).ConfigureAwait(false);
        }

        /// <summary>Removes an alarm condition state by ID from the inner store.</summary>
        /// <param name="alarmId">The ID of the alarm condition state to remove.</param>
        /// <param name="ct">A cancellation token.</param>
        public Task RemoveAsync(string alarmId, CancellationToken ct)
            => _inner.RemoveAsync(alarmId, ct);
    }

    // --- Core.ScriptedAlarms-009: per-alarm evaluation-scratch reuse ---

    /// <summary>Verifies that re-evaluations reuse the same read cache dictionary instance instead of allocating a new one.</summary>
    [Fact]
    public async Task Reevaluation_reuses_the_same_read_cache_dictionary()
    {
        // Pre-009 the engine allocated a fresh Dictionary<string, DataValueSnapshot>
        // on every upstream-change tick; on a busy line this was a steady allocation
        // stream on the hot path. The fix: one dictionary per alarm, refilled in place
        // under _evalGate. Test asserts the dictionary instance is identical across
        // two consecutive evaluations of the same alarm.
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out _);
        await eng.LoadAsync(
            [Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        // First evaluation runs during LoadAsync. Capture the scratch reference now.
        var scratchAfterLoad = eng.TryGetScratchReadCacheForTest("HighTemp");
        scratchAfterLoad.ShouldNotBeNull(
            "the scratch should have been allocated during LoadAsync's initial evaluation");

        // Trigger a re-evaluation by pushing an upstream change.
        up.Push("Temp", 150);
        await WaitForAsync(() =>
            eng.GetState("HighTemp")!.Active == AlarmActiveState.Active);

        var scratchAfterPush = eng.TryGetScratchReadCacheForTest("HighTemp");
        ReferenceEquals(scratchAfterLoad, scratchAfterPush).ShouldBeTrue(
            "the read-cache dictionary must be the *same* instance across evaluations " +
            "(Core.ScriptedAlarms-009) — a per-call allocation would defeat the fix.");
        scratchAfterPush!["Temp"].Value.ShouldBe(150, "refill must update the existing dictionary in place");
    }

    /// <summary>Verifies that re-evaluations reuse the same predicate context instance across evaluations.</summary>
    [Fact]
    public async Task Reevaluation_reuses_the_same_predicate_context()
    {
        // The context wraps the read-cache by reference; refilling the dictionary
        // updates what the script sees. Reusing the context spares a per-call object
        // allocation as well as the dictionary one.
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out _);
        await eng.LoadAsync(
            [Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        var ctxAfterLoad = eng.TryGetScratchContextForTest("HighTemp");
        ctxAfterLoad.ShouldNotBeNull();

        up.Push("Temp", 150);
        await WaitForAsync(() =>
            eng.GetState("HighTemp")!.Active == AlarmActiveState.Active);

        var ctxAfterPush = eng.TryGetScratchContextForTest("HighTemp");
        ReferenceEquals(ctxAfterLoad, ctxAfterPush).ShouldBeTrue(
            "the AlarmPredicateContext must be reused across evaluations (Core.ScriptedAlarms-009).");
    }

    /// <summary>Verifies that LoadAsync clears prior evaluation scratch so new alarms use fresh scratch.</summary>
    [Fact]
    public async Task LoadAsync_drops_the_prior_generations_scratch()
    {
        // A config-publish recreates AlarmStates with potentially different Inputs +
        // Loggers; reusing the prior generation's scratch would attach an outdated
        // logger to the new alarm. LoadAsync must clear _scratchByAlarmId so the
        // next evaluation lazily re-allocates against the fresh AlarmState.
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        using var eng = Build(up, out _);
        await eng.LoadAsync(
            [Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        var scratchAfterFirstLoad = eng.TryGetScratchReadCacheForTest("HighTemp");
        scratchAfterFirstLoad.ShouldNotBeNull();

        // Second LoadAsync — same alarm id, same predicate, but the scratch should be
        // wiped and re-allocated on the next evaluation. (LoadAsync itself triggers a
        // first evaluation, so the scratch is reborn before we look.)
        await eng.LoadAsync(
            [Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
            TestContext.Current.CancellationToken);

        var scratchAfterSecondLoad = eng.TryGetScratchReadCacheForTest("HighTemp");
        scratchAfterSecondLoad.ShouldNotBeNull();
        ReferenceEquals(scratchAfterFirstLoad, scratchAfterSecondLoad).ShouldBeFalse(
            "LoadAsync must drop the prior generation's scratch — reuse across a publish " +
            "would attach a stale Logger / Inputs to the new alarm definition.");
    }

    // --- Core.Scripting-016: engine routes compiles through CompiledScriptCache ---

    /// <summary>Verifies that Dispose unloads the compiled predicate assembly via the script cache.</summary>
    [Fact]
    public void Dispose_unloads_compiled_predicate_assembly()
    {
        // Pre-fix the engine called ScriptEvaluator.Compile directly, so the
        // emitted predicate assembly's ALC stayed loaded for the process lifetime.
        // After the fix the engine routes through CompiledScriptCache; engine
        // Dispose triggers cache Dispose which unloads every cached evaluator's ALC.
        // Assert via WeakReference + GC that the assembly is actually reclaimed.
        // Helper is sync + [NoInlining] so its locals can't be kept alive by an
        // async state machine (an earlier async version of this test failed because
        // the state-machine struct held the evaluator past the method-end).
        var weak = CompileAlarmAndCaptureWeak();
        for (int i = 0; i < 10 && weak.IsAlive; i++)
        {
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
        }
        weak.IsAlive.ShouldBeFalse(
            "engine Dispose must release the compiled-predicate assembly via " +
            "CompiledScriptCache (Core.Scripting-016). If this fails the engine is " +
            "back to calling ScriptEvaluator.Compile directly and -008's headline " +
            "fix doesn't run in production.");
    }

    [System.Runtime.CompilerServices.MethodImpl(
        System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
    private static WeakReference CompileAlarmAndCaptureWeak()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50);
        var eng = Build(up, out _);
        // Block on LoadAsync so this helper stays synchronous — an `async Task`
        // wrapper would compile to a state machine whose generated struct keeps the
        // local `eng` reference alive past the method's apparent end, defeating GC.
        eng.LoadAsync(
            [new ScriptedAlarmDefinition(
                "HighTemp", "Plant/Line1", "HighTemp",
                AlarmKind.AlarmCondition, AlarmSeverity.High,
                "x",
                """return (int)ctx.GetTag("Temp").Value > 100;""")],
            default).GetAwaiter().GetResult();

        // Reach into the engine's compile cache via reflection — the field is
        // private; we only need the Assembly reference, scoped to this NoInlining
        // helper so the locals die when it returns.
        var weak = ExtractEmittedAssemblyWeakRef(eng);
        eng.Dispose();
        return weak;
    }

    [System.Runtime.CompilerServices.MethodImpl(
        System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
    private static WeakReference ExtractEmittedAssemblyWeakRef(ScriptedAlarmEngine eng)
    {
        var cacheField = typeof(ScriptedAlarmEngine).GetField(
            "_compileCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
        var cache = cacheField.GetValue(eng)!;
        var cacheDictField = cache.GetType().GetField(
            "_cache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
        var cacheDict = (System.Collections.IDictionary)cacheDictField.GetValue(cache)!;
        var lazy = cacheDict.Values.Cast<object>().Single();
        var evaluator = lazy.GetType().GetProperty("Value")!.GetValue(lazy)!;
        var del = (Delegate)evaluator.GetType().GetField(
            "_func", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
            .GetValue(evaluator)!;
        return new WeakReference(del.Method.Module.Assembly);
    }
}
