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>
///     Verifies the equipment-tag <b>native-alarm routing</b> wired into <see cref="DriverHostActor"/>
///     (Phase B WS-4c, the LIVE-CONDITION half): a driver child publishes a native alarm transition as
///     <see cref="DriverInstanceActor.AttributeAlarmPublished"/> keyed by the alarm source's
///     <c>SourceNodeId</c> (the equipment tag's wire-ref <c>FullName</c>), but the materialised condition
///     lives at a FOLDER-SCOPED NodeId (<c>{equipmentId}/{folderPath}/{name}</c>). After an apply, the
///     host's <c>_alarmNodeIdByDriverRef</c> map (built only from alarm-bearing EquipmentTags) resolves
///     <c>(DriverInstanceId, SourceNodeId)</c> to that NodeId, the <c>NativeAlarmProjector</c> projects
///     the transition into a full <c>AlarmConditionSnapshot</c>, and <c>ForwardNativeAlarm</c> Tells the
///     publish actor an <see cref="OpcUaPublishActor.AlarmStateUpdate"/> — the same message scripted
///     alarms use.
///
///     <para>
///         Mirrors the value-routing harness in <c>DriverHostActorLiveValueTests</c>: the seeded artifact
///         carries the <c>Namespaces</c> / <c>DriverInstances</c> / <c>Tags</c> arrays, with each alarm
///         tag's <c>TagConfig</c> carrying an <c>alarm</c> object so
///         <c>DeploymentArtifact.ExtractTagAlarm</c> 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);

    /// <summary>A native alarm RAISE whose <c>ConditionId</c> equals the alarm tag's <c>FullName</c> lands on
    /// the condition's folder-scoped NodeId (here <c>eq-1/temp_hi</c>) as an
    /// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> with <c>State.Active == true</c>. The event carries a
    /// production-shaped <c>SourceNodeId</c> (the bare owning object, distinct from <c>ConditionId</c>) so the
    /// lookup is proven to key on <c>ConditionId</c>, not <c>SourceNodeId</c>.</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Native_alarm_raise_routes_to_folder_scoped_condition_NodeId_active()
    {
        var db = NewInMemoryDbFactory();
        // One alarm-bearing equipment tag: eq-1, drv-1, FullName "Temp.HiHi", no folder, Name "temp_hi".
        var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
            Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "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: "Temp.HiHi",    // dotted alarm full-reference = the authored FullName (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("eq-1/temp_hi");
        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 = SeedDeploymentWithAlarmTag(db, RevA,
            Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");

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

        actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
            new StubAlarmHandle(),
            SourceNodeId: "Temp",          // owning object exists, but the condition ref below is unmapped
            ConditionId: "NoSuch.HiHi",    // dotted ref not in the alarm map ⇒ drop
            AlarmType: "OffNormalAlarm",
            Message: "nope",
            Severity: AlarmSeverity.Low,
            SourceTimestampUtc: Ts,
            Kind: AlarmTransitionKind.Raise)));

        // No alarm-condition NodeId for ("drv-1","NoSuch.Alarm") → nothing reaches the sink.
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    /// <summary>Primary (default/unset role) fan-out (Phase B WS-5): 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 folder-scoped condition NodeId, alongside the (ungated) OPC UA condition
    /// update. No <see cref="RedundancyStateChanged"/> is sent, so the cached role is unknown ⇒ emit.</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Native_alarm_publishes_AlarmTransitionEvent_to_alerts_when_primary()
    {
        var db = NewInMemoryDbFactory();
        var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
            Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "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",        // bare owning object (SourceObjectReference) — NOT the lookup key
            ConditionId: "Temp.HiHi",    // dotted alarm full-reference = the authored FullName (the lookup key)
            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("eq-1/temp_hi");

        // Role unknown ⇒ default-emit: exactly one AlarmTransitionEvent on the alerts topic.
        var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
        evt.AlarmId.ShouldBe("eq-1/temp_hi");          // the folder-scoped condition NodeId
        evt.EquipmentPath.ShouldBe("eq-1");            // from the alarm-bearing tag's EquipmentId
        evt.AlarmName.ShouldBe("temp_hi");             // from the 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)
        // This tag's TagConfig.alarm carries no historizeToAveva key ⇒ null ⇒ the HistorianAdapterActor
        // gate (historizeToAveva is not false) still historizes (default-on). Only an explicit false
        // suppresses the durable AVEVA row — see Native_alarm_historizeToAveva_false_threads_through.
        evt.HistorizeToAveva.ShouldBeNull();
        alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // exactly one
    }

    /// <summary>Native-alarm HistorizeToAveva opt-out (Task 3): 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
    /// (<c>historizeToAveva is not false</c>) suppresses the durable AVEVA write — the same opt-out the
    /// scripted-alarm plan flag drives. The live <c>/alerts</c> fan-out is unaffected (the transition still
    /// publishes; only the durable row is gated downstream).</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Native_alarm_historizeToAveva_false_threads_through()
    {
        var db = NewInMemoryDbFactory();
        var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
            Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "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: "Temp.HiHi",
            AlarmType: "OffNormalAlarm",
            Message: "temperature high",
            Severity: AlarmSeverity.High,
            SourceTimestampUtc: Ts,
            Kind: AlarmTransitionKind.Raise)));

        var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
        evt.AlarmId.ShouldBe("eq-1/temp_hi");
        // The explicit opt-out rides onto the transition ⇒ the historian gate suppresses the durable row.
        evt.HistorizeToAveva.ShouldBe(false);
    }

    /// <summary>Native-alarm HistorizeToAveva opt-IN (Task 3): an explicit <c>historizeToAveva: true</c>
    /// rides through as <c>true</c> (distinct from the absent ⇒ null default-on case) so an operator who
    /// deliberately opts in is recorded as such on the transition.</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Native_alarm_historizeToAveva_true_threads_through()
    {
        var db = NewInMemoryDbFactory();
        var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
            Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "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: "Temp.HiHi",
            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 (Phase B WS-5): when the cached local role is Secondary the host
    /// MUST still write the local OPC UA condition node (ungated — keeps the standby's address space warm
    /// for failover) but MUST NOT publish the cluster-wide <c>alerts</c> transition (the Primary publishes
    /// the single fleet-wide copy).</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Secondary_node_suppresses_alerts_publish_but_still_updates_condition()
    {
        var db = NewInMemoryDbFactory();
        var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
            Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "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",        // bare owning object (SourceObjectReference) — NOT the lookup key
            ConditionId: "Temp.HiHi",    // dotted alarm full-reference = the authored FullName (the lookup key)
            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("eq-1/temp_hi");
        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>DriverHostActor.ForwardNativeAlarm</c> into the published <see cref="AlarmTransitionEvent"/>:
    /// <c>Comment</c> carries the operator string and <c>User</c> is <c>"device"</c> (a non-null comment
    /// signals the upstream alarm system provided an operator origin, but without a specific user identity).
    /// </summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Native_alarm_operator_comment_flows_to_transition_event()
    {
        var db = NewInMemoryDbFactory();
        var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
            Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");

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

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

        // Send an alarm whose OperatorComment is set — simulates an upstream acknowledge-with-comment.
        actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
            new StubAlarmHandle(),
            SourceNodeId: "Temp",
            ConditionId: "Temp.HiHi",
            AlarmType: "OffNormalAlarm",
            Message: "investigating",
            Severity: AlarmSeverity.High,
            SourceTimestampUtc: Ts,
            Kind: AlarmTransitionKind.Acknowledge,
            OperatorComment: "investigating")));

        // OPC UA condition update is ungated — drain it.
        publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));

        // The published AlarmTransitionEvent must carry the comment + the "device" user marker.
        var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
        evt.Comment.ShouldBe("investigating");
        evt.User.ShouldBe("device");
    }

    /// <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. Mirrors the
    /// ScriptedAlarmHostActor test harness.</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"/>, which equals the host's <c>_localNode</c>) with <paramref name="role"/>
    /// so the alerts-publish gate observes the local role.</summary>
    private static void TellRedundancyRole(IActorRef host, RedundancyRole role)
    {
        host.Tell(new RedundancyStateChanged(
            new[]
            {
                new NodeRedundancyState(
                    NodeId: TestNode,
                    Role: role,
                    IsClusterLeader: role == RedundancyRole.Primary,
                    IsRoleLeaderForDriver: 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, Akka.TestKit.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 one alarm-bearing equipment tag: the tag's
    ///     <c>TagConfig</c> carries both a <c>FullName</c> and an <c>alarm</c> object
    ///     (<c>alarmType</c> + <c>severity</c>) so <c>DeploymentArtifact.ExtractTagAlarm</c> projects a
    ///     non-null <c>EquipmentTagAlarmInfo</c> (here <c>OffNormalAlarm</c> / 700) — making the tag a
    ///     condition rather than a value variable.
    /// </summary>
    private static DeploymentId SeedDeploymentWithAlarmTag(
        IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
        string Equip, string Driver, string FullName, string? Folder, string Name,
        bool? historizeToAveva = null)
    {
        // historizeToAveva absent (null) ⇒ omit the key entirely 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
        {
            Namespaces = new[]
            {
                new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
            },
            DriverInstances = new[]
            {
                new { DriverInstanceId = Driver, NamespaceId = "ns-eq" },
            },
            Tags = new[]
            {
                new
                {
                    TagId = "tag-0",
                    EquipmentId = Equip,
                    DriverInstanceId = Driver,
                    Name,
                    FolderPath = Folder,
                    DataType = "Boolean",
                    TagConfig = JsonSerializer.Serialize(new
                    {
                        FullName,
                        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";
    }
}
