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) =>
        new(AlarmId: id,
            EquipmentPath: "Plant/Line1/Reactor",
            AlarmName: id,
            Kind: AlarmKind.AlarmCondition,
            Severity: sev,
            MessageTemplate: msg,
            PredicateScriptSource: predicate);

    [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);
    }

    [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");
    }

    [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);
    }

    [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);
    }

    [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");
    }

    [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();
    }

    [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");
    }

    [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");
    }

    [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);
    }

    [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);
    }

    [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");
    }

    [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);
    }

    [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);
    }

    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");
    }
}
