using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.TestKit;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;

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

/// <summary>
/// Verifies <see cref="ScriptedAlarmHostActor"/> loads the enabled subset of
/// <see cref="EquipmentScriptedAlarmPlan"/>s into its <see cref="ScriptedAlarmEngine"/>, registers mux
/// interest for their dependency refs after the load completes, feeds live
/// <see cref="VirtualTagActor.DependencyValueChanged"/> values into the engine, and bridges the
/// engine's emissions to both an <see cref="OpcUaPublishActor.AlarmStateUpdate"/> and an
/// <see cref="AlarmTransitionEvent"/> on the cluster <c>alerts</c> topic.
/// </summary>
public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
{
    private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(8);

    /// <summary>Plan whose predicate compares the single tag "M.T" against 90 — enabled by default.</summary>
    private static EquipmentScriptedAlarmPlan Plan(
        string id = "alm-1",
        string equipmentId = "Plant/Line1/M",
        string name = "HighTemp",
        string depRef = "M.T",
        int threshold = 90,
        bool enabled = true,
        int severity = 800) =>
        new(
            ScriptedAlarmId: id,
            EquipmentId: equipmentId,
            Name: name,
            AlarmType: "AlarmCondition",
            Severity: severity,
            MessageTemplate: "condition",
            PredicateScriptId: $"{id}-script",
            PredicateSource: $"return (int)ctx.GetTag(\"{depRef}\").Value > {threshold};",
            DependencyRefs: new[] { depRef },
            HistorizeToAveva: true,
            Retain: true,
            Enabled: enabled);

    /// <summary>Plan whose predicate references an unknown identifier so it fails to compile — applying it
    /// faults the engine's LoadAsync. Used to prove a faulted load doesn't crash the host.</summary>
    private static EquipmentScriptedAlarmPlan BadPlan(string id = "bad-1") =>
        new(
            ScriptedAlarmId: id,
            EquipmentId: "Plant/Line1/M",
            Name: "Broken",
            AlarmType: "AlarmCondition",
            Severity: 500,
            MessageTemplate: "broken",
            PredicateScriptId: $"{id}-script",
            PredicateSource: "return unknownIdentifier;", // uncompilable → LoadAsync throws
            DependencyRefs: Array.Empty<string>(),
            HistorizeToAveva: false,
            Retain: false,
            Enabled: true);

    private static ScriptedAlarmEngine BuildEngine(DependencyMuxTagUpstreamSource upstream)
    {
        var logger = new LoggerConfiguration().CreateLogger();
        return new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger);
    }

    private (IActorRef Host, DependencyMuxTagUpstreamSource Upstream) Spawn(
        TestProbe publish, TestProbe mux)
    {
        var upstream = new DependencyMuxTagUpstreamSource();
        var engine = BuildEngine(upstream);
        var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(publish.Ref, mux.Ref, upstream, engine));
        return (host, upstream);
    }

    /// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.
    /// The Subscribe is sent FROM the probe so the SubscribeAck returns to it.</summary>
    private void SubscribeToAlerts(TestProbe probe)
    {
        DistributedPubSub.Get(Sys).Mediator.Tell(
            new Subscribe(ScriptedAlarmHostActor.AlertsTopic, probe.Ref), probe.Ref);
        probe.ExpectMsg<SubscribeAck>(Timeout);
    }

    /// <summary>Load + interest: applying one enabled alarm registers mux interest for its dep ref
    /// AFTER the engine load completes; a disabled alarm in the same apply contributes no dep ref.</summary>
    [Fact]
    public void Apply_loads_enabled_alarm_and_registers_interest()
    {
        var publish = CreateTestProbe();
        var mux = CreateTestProbe();
        var (host, _) = Spawn(publish, mux);

        host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[]
        {
            Plan(id: "alm-1", depRef: "M.T"),
            Plan(id: "alm-2", depRef: "M.X", enabled: false), // disabled — not loaded, dep absent
        }));

        var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
        reg.TagRefs.ShouldContain("M.T");
        reg.TagRefs.ShouldNotContain("M.X");
    }

    /// <summary>Activation path: with the alarm loaded, pushing a value above the threshold drives an
    /// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true) and an
    /// AlarmTransitionEvent("Activated") on the alerts topic.</summary>
    [Fact]
    public void Dependency_change_above_threshold_activates_alarm()
    {
        var publish = CreateTestProbe();
        var mux = CreateTestProbe();
        var alerts = CreateTestProbe();
        SubscribeToAlerts(alerts);

        var (host, _) = Spawn(publish, mux);
        host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(severity: 800) }));
        mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed

        var now = DateTime.UtcNow;
        host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, now));

        var state = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
        state.AlarmNodeId.ShouldBe("alm-1");
        // The full Part 9 snapshot bridges through (T15) — every Core condition field maps:
        // on activation the engine sets Active, clears Ack AND Confirm (a new active occurrence needs a
        // fresh ack→clear→confirm cycle), keeps Enabled, and leaves Shelving unshelved.
        state.State.Active.ShouldBeTrue();              // Condition.Active == Active
        state.State.Acknowledged.ShouldBeFalse();       // Condition.Acked == Unacknowledged on activation
        state.State.Confirmed.ShouldBeFalse();          // Condition.Confirmed == Unconfirmed on activation
        state.State.Enabled.ShouldBeTrue();             // Condition.Enabled == Enabled
        state.State.Shelving.ShouldBe(AlarmShelvingKind.Unshelved); // Condition.Shelving.Kind == Unshelved
        state.State.Severity.ShouldBe((ushort)1000);    // 800 → Critical bucket → 1000
        state.State.Message.ShouldBe("condition");      // e.Message

        var evt = alerts.ExpectMsg<AlarmTransitionEvent>(Timeout);
        evt.AlarmId.ShouldBe("alm-1");
        evt.TransitionKind.ShouldBe("Activated");
        evt.Severity.ShouldBe(1000); // 800 → Critical bucket → 1000
        evt.User.ShouldBe("system");
    }

    /// <summary>Clear path: after activating, pushing a value below the threshold drives Active→Inactive
    /// — AlarmStateUpdate(Active=false) + AlarmTransitionEvent("Cleared").</summary>
    [Fact]
    public void Dependency_change_below_threshold_clears_alarm()
    {
        var publish = CreateTestProbe();
        var mux = CreateTestProbe();
        var alerts = CreateTestProbe();
        SubscribeToAlerts(alerts);

        var (host, _) = Spawn(publish, mux);
        host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
        mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);

        // Activate first.
        host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow));
        publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => m.State.Active, Timeout);
        alerts.FishForMessage<AlarmTransitionEvent>(e => e.TransitionKind == "Activated", Timeout);

        // Now clear.
        host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 10, DateTime.UtcNow));

        var cleared = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => !m.State.Active, Timeout);
        cleared.AlarmNodeId.ShouldBe("alm-1");

        var evt = alerts.FishForMessage<AlarmTransitionEvent>(e => e.TransitionKind == "Cleared", Timeout);
        evt.AlarmId.ShouldBe("alm-1");
    }

    /// <summary>Re-apply reloads: a second ApplyScriptedAlarms with a different alarm set loads the new
    /// alarm — a fresh RegisterInterest reflecting the new dependency refs lands on the mux.</summary>
    [Fact]
    public void Reapply_reloads_with_new_dependency_refs()
    {
        var publish = CreateTestProbe();
        var mux = CreateTestProbe();
        var (host, _) = Spawn(publish, mux);

        host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T") }));
        var first = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
        first.TagRefs.ShouldContain("M.T");

        host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-9", depRef: "M.Y") }));
        var second = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
        second.TagRefs.ShouldContain("M.Y");
        second.TagRefs.ShouldNotContain("M.T");
    }

    /// <summary>Faulted load resilience: applying an alarm whose predicate doesn't compile faults the
    /// engine's LoadAsync. The host logs a Warning (Status.Failure handler) and stays alive — it must
    /// still process a subsequent valid apply, registering interest for that apply's dep refs.</summary>
    [Fact]
    public void Faulted_load_does_not_crash_host()
    {
        var publish = CreateTestProbe();
        var mux = CreateTestProbe();
        var (host, _) = Spawn(publish, mux);

        // Apply a plan that fails to compile — LoadAsync faults, the host swallows it as a Warning and
        // does NOT register interest (no dep refs to register, and the load never completed).
        host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { BadPlan() }));
        mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500));

        // Prove the actor is still responsive: a later valid apply loads + registers interest as normal.
        host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-ok", depRef: "M.T") }));
        var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
        reg.TagRefs.ShouldContain("M.T");
    }

    /// <summary>Stale-load guard (fix A): two back-to-back applies with different dep-ref sets must end
    /// with the mux holding the SECOND (latest-generation) set — a stale earlier completion must never
    /// re-introduce the first set's refs.
    ///
    /// <para>Limitation: forcing the two LoadAsync continuations to complete out of order is not
    /// deterministic via real async timing (both loads are short + run on the thread pool). This test
    /// therefore validates the guard's observable contract rather than the race itself: it fishes for
    /// the RegisterInterest carrying the second apply's refs, then asserts no LATER RegisterInterest
    /// re-introduces the first apply's refs. With the generation guard in place the latest apply always
    /// wins; without it, an out-of-order stale completion could land a "M.A"-bearing RegisterInterest
    /// after the "M.B" one — which this assertion would catch.</para></summary>
    [Fact]
    public void Stale_load_does_not_register_superseded_dep_refs()
    {
        var publish = CreateTestProbe();
        var mux = CreateTestProbe();
        var (host, _) = Spawn(publish, mux);

        // Fire two applies in quick succession with disjoint dep refs; the second supersedes the first.
        host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-a", depRef: "M.A") }));
        host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-b", depRef: "M.B") }));

        // The latest generation must win: fish for the RegisterInterest reflecting the second apply.
        mux.FishForMessage<DependencyMuxActor.RegisterInterest>(r => r.TagRefs.Contains("M.B"), Timeout);

        // No LATER RegisterInterest may re-introduce the first (superseded) apply's "M.A" ref.
        mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }
}
