using System.Text.Json;
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.TestKit;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;

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

/// <summary>
///     v3 Batch 4 (B4-WP4) — the equipment-tag <b>native-alarm routing</b> wired into
///     <see cref="DriverHostActor"/> re-expressed for the v3 raw-condition model. A native alarm is a SINGLE
///     Part 9 condition materialised at the RAW tag (ConditionId = its RawPath, Raw realm) — NOT a folder-scoped
///     equipment-tag NodeId. After an apply, the host's <c>_alarmNodeIdByDriverRef</c> map (built from the
///     alarm-bearing <c>composition.RawTags</c>) resolves <c>(DriverInstanceId, RawPath)</c> to the raw
///     condition NodeId, the <c>NativeAlarmProjector</c> projects the transition into a full
///     <c>AlarmConditionSnapshot</c>, and <c>ForwardNativeAlarm</c> Tells the publish actor a single
///     <see cref="OpcUaPublishActor.AlarmStateUpdate"/> (Raw realm) — the fan-out to referencing equipment
///     happens at the SDK-notifier level (WP4 node-manager wiring), NOT by re-reporting per root.
///
///     <para>
///         Mirrors the v3 value-routing harness in <c>DriverHostActorLiveValueTests</c>: the seeded artifact
///         carries the v3 raw-tag chain (RawFolder → DriverInstance(RawFolderId) → Device → Tag) with the alarm
///         tag's <c>TagConfig</c> carrying an <c>alarm</c> object so the composer projects a non-null
///         <c>EquipmentTagAlarmInfo</c>. The OPC UA sink + dependency mux are injected as TestProbes.
///     </para>
/// </summary>
public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
{
    private static readonly NodeId TestNode = NodeId.Parse("driver-alarm-test");
    private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
    private static readonly DateTime Ts = new(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc);

    // The v3 RawPath for the seeded alarm tag: RawFolder "Plant" / DriverName "Modbus" / Device "dev1" / Tag.
    private const string AlarmRawPath = "Plant/Modbus/dev1/temp_hi";

    /// <summary>A native alarm RAISE whose <c>ConditionId</c> equals the alarm tag's RawPath (the v3 wire-ref)
    /// lands on the RAW condition NodeId (== the RawPath, Raw realm) as an
    /// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> with <c>State.Active == true</c>. The event carries a
    /// distinct <c>SourceNodeId</c> so the lookup is proven to key on <c>ConditionId</c>, not
    /// <c>SourceNodeId</c>.</summary>
    [Fact]
    public void Native_alarm_raise_routes_to_raw_condition_NodeId_active()
    {
        var db = NewInMemoryDbFactory();
        var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");

        var (actor, publish) = SpawnHostAndApply(db, deploymentId);

        actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
            new StubAlarmHandle(),
            SourceNodeId: "Temp",        // bare owning object (SourceObjectReference) — NOT the lookup key
            ConditionId: AlarmRawPath,   // the v3 wire-ref RawPath = the lookup key
            AlarmType: "OffNormalAlarm",
            Message: "temperature high",
            Severity: AlarmSeverity.High,
            SourceTimestampUtc: Ts,
            Kind: AlarmTransitionKind.Raise)));

        var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
        update.AlarmNodeId.ShouldBe(AlarmRawPath);
        update.Realm.ShouldBe(AddressSpaceRealm.Raw);
        update.State.Active.ShouldBeTrue();
        update.State.Acknowledged.ShouldBeFalse();
        update.TimestampUtc.ShouldBe(Ts);
    }

    /// <summary>An <see cref="DriverInstanceActor.AttributeAlarmPublished"/> whose <c>ConditionId</c> is not in
    /// the alarm map produces NO <see cref="OpcUaPublishActor.AlarmStateUpdate"/> (unknown-ref drop).</summary>
    [Fact]
    public void Unknown_alarm_ref_produces_no_AlarmStateUpdate()
    {
        var db = NewInMemoryDbFactory();
        var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");

        var (actor, publish) = SpawnHostAndApply(db, deploymentId);

        actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
            new StubAlarmHandle(),
            SourceNodeId: "Temp",
            ConditionId: "Plant/Modbus/dev1/no_such",  // unmapped RawPath ⇒ drop
            AlarmType: "OffNormalAlarm",
            Message: "nope",
            Severity: AlarmSeverity.Low,
            SourceTimestampUtc: Ts,
            Kind: AlarmTransitionKind.Raise)));

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

    /// <summary>Primary (default/unset role) fan-out: a native alarm RAISE on a known ref publishes exactly one
    /// <see cref="AlarmTransitionEvent"/> to the cluster <c>alerts</c> topic with <c>AlarmId</c> = the raw
    /// condition NodeId (RawPath), alongside the (ungated) OPC UA condition update.</summary>
    [Fact]
    public void Native_alarm_publishes_AlarmTransitionEvent_to_alerts_when_primary()
    {
        var db = NewInMemoryDbFactory();
        var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");

        var alerts = CreateTestProbe();
        SubscribeToAlerts(alerts);

        var (actor, publish) = SpawnHostAndApply(db, deploymentId);

        actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
            new StubAlarmHandle(),
            SourceNodeId: "Temp",
            ConditionId: AlarmRawPath,
            AlarmType: "OffNormalAlarm",
            Message: "temperature high",
            Severity: AlarmSeverity.High,
            SourceTimestampUtc: Ts,
            Kind: AlarmTransitionKind.Raise)));

        // The OPC UA condition update is UNGATED — it must arrive.
        var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
        update.AlarmNodeId.ShouldBe(AlarmRawPath);

        // Role unknown ⇒ default-emit: exactly one AlarmTransitionEvent on the alerts topic.
        var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
        evt.AlarmId.ShouldBe(AlarmRawPath);            // the raw condition NodeId (v3: ConditionId == RawPath)
        evt.EquipmentPath.ShouldBe(AlarmRawPath);      // v3: the alarm meta keys the display off the RawPath
        evt.AlarmName.ShouldBe("temp_hi");             // from the raw tag's Name
        evt.TransitionKind.ShouldBe("Activated");      // native Kind → canonical EmissionKind vocabulary (Raise → Activated)
        evt.AlarmTypeName.ShouldBe("OffNormalAlarm");  // the tag's alarm AlarmType
        evt.Severity.ShouldBe(700);                    // AlarmSeverity.High → projector 700
        evt.Message.ShouldBe("temperature high");
        evt.User.ShouldBe(string.Empty);              // no operator comment ⇒ device-origin (empty user)
        evt.HistorizeToAveva.ShouldBeNull();           // absent historizeToAveva key ⇒ null ⇒ default-on
        alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // exactly one
    }

    /// <summary>Native-alarm HistorizeToAveva opt-out: a tag whose <c>TagConfig.alarm</c> carries
    /// <c>historizeToAveva: false</c> publishes its <see cref="AlarmTransitionEvent"/> with
    /// <c>HistorizeToAveva == false</c>, so the runtime's <c>HistorianAdapterActor</c> gate suppresses the
    /// durable AVEVA write. The live <c>/alerts</c> fan-out is unaffected.</summary>
    [Fact]
    public void Native_alarm_historizeToAveva_false_threads_through()
    {
        var db = NewInMemoryDbFactory();
        var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", historizeToAveva: false);

        var alerts = CreateTestProbe();
        SubscribeToAlerts(alerts);

        var (actor, publish) = SpawnHostAndApply(db, deploymentId);

        actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
            new StubAlarmHandle(),
            SourceNodeId: "Temp",
            ConditionId: AlarmRawPath,
            AlarmType: "OffNormalAlarm",
            Message: "temperature high",
            Severity: AlarmSeverity.High,
            SourceTimestampUtc: Ts,
            Kind: AlarmTransitionKind.Raise)));

        var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
        evt.AlarmId.ShouldBe(AlarmRawPath);
        evt.HistorizeToAveva.ShouldBe(false);
    }

    /// <summary>Native-alarm HistorizeToAveva opt-IN: an explicit <c>historizeToAveva: true</c> rides through as
    /// <c>true</c> (distinct from the absent ⇒ null default-on case).</summary>
    [Fact]
    public void Native_alarm_historizeToAveva_true_threads_through()
    {
        var db = NewInMemoryDbFactory();
        var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", historizeToAveva: true);

        var alerts = CreateTestProbe();
        SubscribeToAlerts(alerts);

        var (actor, publish) = SpawnHostAndApply(db, deploymentId);

        actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
            new StubAlarmHandle(),
            SourceNodeId: "Temp",
            ConditionId: AlarmRawPath,
            AlarmType: "OffNormalAlarm",
            Message: "temperature high",
            Severity: AlarmSeverity.High,
            SourceTimestampUtc: Ts,
            Kind: AlarmTransitionKind.Raise)));

        var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
        evt.HistorizeToAveva.ShouldBe(true);
    }

    /// <summary>Secondary suppression: when the cached local role is Secondary the host MUST still write the
    /// local OPC UA condition node (ungated — keeps the standby warm) but MUST NOT publish the cluster-wide
    /// <c>alerts</c> transition (the Primary publishes the single fleet-wide copy).</summary>
    [Fact]
    public void Secondary_node_suppresses_alerts_publish_but_still_updates_condition()
    {
        var db = NewInMemoryDbFactory();
        var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");

        var alerts = CreateTestProbe();
        SubscribeToAlerts(alerts);

        var (actor, publish) = SpawnHostAndApply(db, deploymentId);

        // Mark this node Secondary so the alerts publish is gated off.
        TellRedundancyRole(actor, RedundancyRole.Secondary);

        actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
            new StubAlarmHandle(),
            SourceNodeId: "Temp",
            ConditionId: AlarmRawPath,
            AlarmType: "OffNormalAlarm",
            Message: "temperature high",
            Severity: AlarmSeverity.High,
            SourceTimestampUtc: Ts,
            Kind: AlarmTransitionKind.Raise)));

        // The OPC UA condition update is UNGATED — it must still arrive on the secondary.
        var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
        update.AlarmNodeId.ShouldBe(AlarmRawPath);
        update.State.Active.ShouldBeTrue();

        // The cluster-wide alerts publish is gated off on the secondary.
        alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    /// <summary>A native alarm whose <c>AlarmEventArgs.OperatorComment</c> is set flows through
    /// <c>ForwardNativeAlarm</c> into the published <see cref="AlarmTransitionEvent"/>: <c>Comment</c> carries
    /// the operator string and <c>User</c> is <c>"device"</c>.</summary>
    [Fact]
    public void Native_alarm_operator_comment_flows_to_transition_event()
    {
        var db = NewInMemoryDbFactory();
        var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");

        var alerts = CreateTestProbe();
        SubscribeToAlerts(alerts);

        var (actor, publish) = SpawnHostAndApply(db, deploymentId);

        actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
            new StubAlarmHandle(),
            SourceNodeId: "Temp",
            ConditionId: AlarmRawPath,
            AlarmType: "OffNormalAlarm",
            Message: "investigating",
            Severity: AlarmSeverity.High,
            SourceTimestampUtc: Ts,
            Kind: AlarmTransitionKind.Acknowledge,
            OperatorComment: "investigating")));

        publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));

        var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
        evt.Comment.ShouldBe("investigating");
        evt.User.ShouldBe("device");
    }

    /// <summary>#477 Layer 2 — a driver <see cref="DriverInstanceActor.ConnectivityChanged"/> annotates the
    /// Quality of the driver's native conditions via <see cref="OpcUaPublishActor.AlarmQualityUpdate"/>: comms
    /// lost → Bad, restored → Good, at the raw condition NodeId + realm. No alarm transition is involved — this
    /// is the ONLY signal for a comms-lost native source.</summary>
    [Fact]
    public void Driver_disconnect_and_reconnect_annotate_native_condition_quality()
    {
        var db = NewInMemoryDbFactory();
        var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");

        var (actor, publish) = SpawnHostAndApply(db, deploymentId);

        // Comms lost → the raw condition is annotated Bad.
        actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false));
        var bad = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
        bad.AlarmNodeId.ShouldBe(AlarmRawPath);
        bad.Realm.ShouldBe(AddressSpaceRealm.Raw);
        bad.Quality.ShouldBe(OpcUaQuality.Bad);

        // Comms restored → annotated Good.
        actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true));
        var good = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
        good.AlarmNodeId.ShouldBe(AlarmRawPath);
        good.Quality.ShouldBe(OpcUaQuality.Good);
    }

    /// <summary>#477 Layer 2 — connectivity for a DIFFERENT driver instance annotates none of this driver's
    /// conditions (the fan-out is scoped by DriverInstanceId).</summary>
    [Fact]
    public void Connectivity_for_another_driver_annotates_nothing()
    {
        var db = NewInMemoryDbFactory();
        var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");

        var (actor, publish) = SpawnHostAndApply(db, deploymentId);

        actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-OTHER", Connected: false));
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    /// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.</summary>
    private void SubscribeToAlerts(TestProbe probe)
    {
        DistributedPubSub.Get(Sys).Mediator.Tell(
            new Subscribe(ScriptedAlarmHostActor.AlertsTopic, probe.Ref), probe.Ref);
        probe.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(5));
    }

    /// <summary>Tell the host a <see cref="RedundancyStateChanged"/> snapshot marking the host's own node
    /// (<see cref="TestNode"/>) with <paramref name="role"/> so the alerts-publish gate observes it.</summary>
    private static void TellRedundancyRole(IActorRef host, RedundancyRole role)
    {
        host.Tell(new RedundancyStateChanged(
            new[]
            {
                new NodeRedundancyState(
                    NodeId: TestNode,
                    Role: role,
                    IsClusterLeader: role == RedundancyRole.Primary,
                    IsDriverPrimary: role == RedundancyRole.Primary,
                    AsOfUtc: DateTime.UtcNow),
            },
            CorrelationId.NewId()));
    }

    /// <summary>Spawns the host with a publish probe, dispatches the deployment, and waits for the Applied ACK
    /// so the apply (and thus the alarm-map build in PushDesiredSubscriptions) has completed before the test
    /// publishes an alarm. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
    private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply(
        IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
    {
        var coordinator = CreateTestProbe();
        var publish = CreateTestProbe();
        var mux = CreateTestProbe();
        var vtHost = CreateTestProbe();

        var actor = Sys.ActorOf(DriverHostActor.Props(
            db, TestNode, coordinator.Ref,
            localRoles: new HashSet<string> { "driver" },
            dependencyMux: mux.Ref,
            opcUaPublishActor: publish.Ref,
            virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
            virtualTagHostOverride: vtHost.Ref));

        actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);

        // RebuildAddressSpace also lands on the publish probe during apply; drain it so the test's
        // ExpectMsg<AlarmStateUpdate> assertions see only alarm updates.
        publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));

        return (actor, publish);
    }

    /// <summary>
    ///     Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
    ///     DriverInstance(RawFolderId, Name "Modbus") → Device "dev1" → Tag) with the tag's <c>TagConfig</c>
    ///     carrying an <c>alarm</c> object so the composer projects a non-null <c>EquipmentTagAlarmInfo</c>
    ///     (<c>OffNormalAlarm</c> / 700) — making the raw tag a Part 9 condition at its RawPath rather than a
    ///     value variable. Enums serialize numerically. No UNS reference is needed for these routing/emit tests
    ///     (the SDK-notifier fan-out to referencing equipment is covered by the node-manager tests).
    /// </summary>
    private static DeploymentId SeedV3AlarmDeployment(
        IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
        string Driver, string Tag, bool? historizeToAveva = null)
    {
        // historizeToAveva absent (null) ⇒ omit the key so the absent ⇒ historize default path is exercised;
        // a concrete true/false writes the bool into the alarm object so the native path threads it.
        object alarm = historizeToAveva is { } h
            ? new { alarmType = "OffNormalAlarm", severity = 700, historizeToAveva = h }
            : new { alarmType = "OffNormalAlarm", severity = 700 };

        var artifact = JsonSerializer.SerializeToUtf8Bytes(new
        {
            RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
            DriverInstances = new[]
            {
                new { DriverInstanceId = Driver, RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
            },
            Devices = new[]
            {
                new { DeviceId = $"{Driver}:dev1", DriverInstanceId = Driver, Name = "dev1", DeviceConfig = "{}" },
            },
            TagGroups = Array.Empty<object>(),
            Tags = new[]
            {
                new
                {
                    TagId = "tag-0",
                    DeviceId = $"{Driver}:dev1",
                    TagGroupId = (string?)null,
                    Name = Tag,
                    DataType = "Boolean",
                    AccessLevel = 0, // TagAccessLevel.Read
                    TagConfig = JsonSerializer.Serialize(new { alarm }),
                },
            },
        });

        var id = DeploymentId.NewId();
        using var ctx = db.CreateDbContext();
        ctx.Deployments.Add(new Deployment
        {
            DeploymentId = id.Value,
            RevisionHash = rev.Value,
            Status = DeploymentStatus.Sealed,
            CreatedBy = "test",
            SealedAtUtc = DateTime.UtcNow,
            ArtifactBlob = artifact,
        });
        ctx.SaveChanges();
        return id;
    }

    /// <summary>Minimal <see cref="IAlarmSubscriptionHandle"/> for building <see cref="AlarmEventArgs"/>.</summary>
    private sealed class StubAlarmHandle : IAlarmSubscriptionHandle
    {
        /// <summary>Gets the diagnostic ID of the alarm subscription.</summary>
        public string DiagnosticId => "stub-alarm-sub";
    }
}
