using System.Collections.Concurrent;
using Akka.Actor;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
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.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;

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

public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
{
    /// <summary>Verifies that message contracts are accepted without a pinned dispatcher in tests.</summary>
    [Fact]
    public void Accepts_message_contracts_without_pinned_dispatcher_in_tests()
    {
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests());
        actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
        actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
        actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
        actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240));

        ExpectNoMsg(TimeSpan.FromMilliseconds(200));
    }

    /// <summary>Verifies that production props target the OPC UA synchronized dispatcher.</summary>
    [Fact]
    public void Production_Props_targets_opcua_synchronized_dispatcher()
    {
        var props = OpcUaPublishActor.Props();
        props.Dispatcher.ShouldBe(OpcUaPublishActor.DispatcherId);
    }

    /// <summary>Verifies that AttributeValueUpdate routes to sink WriteValue.</summary>
    [Fact]
    public void AttributeValueUpdate_routes_to_sink_WriteValue()
    {
        var sink = new RecordingSink();
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));

        actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
        actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaQuality.Uncertain, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));

        AwaitAssert(() =>
        {
            sink.Values.Count.ShouldBe(2);
            sink.Values[0].NodeId.ShouldBe("ns=2;s=T1");
            sink.Values[0].Value.ShouldBe(3.14);
            sink.Values[0].Quality.ShouldBe(OpcUaQuality.Good);
            sink.Values[1].Quality.ShouldBe(OpcUaQuality.Uncertain);
        }, duration: PresenceBudget);
    }

    /// <summary>Verifies that AlarmStateUpdate routes to sink WriteAlarmCondition with the full snapshot.</summary>
    [Fact]
    public void AlarmStateUpdate_routes_to_sink_WriteAlarmCondition()
    {
        var sink = new RecordingSink();
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));

        actor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
            "ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));

        AwaitAssert(() =>
        {
            sink.Alarms.Count.ShouldBe(1);
            sink.Alarms[0].AlarmNodeId.ShouldBe("ns=2;s=A1");
            sink.Alarms[0].State.Active.ShouldBeTrue();
            sink.Alarms[0].State.Acknowledged.ShouldBeFalse();
            sink.Alarms[0].State.Severity.ShouldBe((ushort)700);
        }, duration: PresenceBudget);
    }

    /// <summary>#477 — AlarmQualityUpdate routes to sink.WriteAlarmQuality with the quality + realm.</summary>
    [Fact]
    public void AlarmQualityUpdate_routes_to_sink_WriteAlarmQuality()
    {
        var sink = new RecordingSink();
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));

        actor.Tell(new OpcUaPublishActor.AlarmQualityUpdate(
            "Plant/Modbus/dev1/temp_hi", OpcUaQuality.Bad, DateTime.UtcNow, Realm: AddressSpaceRealm.Raw));

        AwaitAssert(() =>
        {
            sink.AlarmQualityQueue.Count.ShouldBe(1);
            sink.AlarmQualityQueue.TryPeek(out var q).ShouldBeTrue();
            q.AlarmNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi");
            q.Quality.ShouldBe(OpcUaQuality.Bad);
            q.Realm.ShouldBe(AddressSpaceRealm.Raw);
        }, duration: PresenceBudget);
    }

    /// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each test
    /// only specifies the fields it cares about.</summary>
    private static AlarmConditionSnapshot Snapshot(
        bool active = false,
        bool acknowledged = true,
        bool confirmed = true,
        bool enabled = true,
        AlarmShelvingKind shelving = AlarmShelvingKind.Unshelved,
        ushort severity = 500,
        string message = "test") =>
        new(active, acknowledged, confirmed, enabled, shelving, severity, message);

    /// <summary>Verifies that RebuildAddressSpace calls sink Rebuild.</summary>
    [Fact]
    public void RebuildAddressSpace_calls_sink_Rebuild()
    {
        var sink = new RecordingSink();
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));

        actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));

        AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget);
    }

    /// <summary>Verifies that ServiceLevelChanged publishes to IServiceLevelPublisher once per unique level.</summary>
    [Fact]
    public void ServiceLevelChanged_publishes_to_IServiceLevelPublisher_once_per_unique_level()
    {
        var publisher = new RecordingPublisher();
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(serviceLevel: publisher));

        actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240));
        actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240)); // dedup
        actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100));

        AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 100 }),
            duration: PresenceBudget);
    }

    /// <summary>Verifies that the very first computed ServiceLevel is always published even when it is
    /// 0 — a node starting Detached (or role-less) must publish 0 rather than let the SDK default
    /// (255 = full service) stand. The dedup against the <c>byte</c>-default <c>0</c> must not swallow
    /// the first publish. Drives a Detached local entry (first computed level = 0) with no DB-health
    /// probe and asserts the publisher saw exactly [0].</summary>
    [Fact]
    public void First_service_level_zero_is_published_not_deduped()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("detached-node");
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local));

        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Detached,
                    IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
            },
            CorrelationId.NewId()));

        AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 0 }),
            duration: PresenceBudget);
    }

    /// <summary>Verifies that RedundancyStateChanged drives local ServiceLevel publish for primary leader.</summary>
    [Fact]
    public void RedundancyStateChanged_drives_local_ServiceLevel_publish_for_primary_leader()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("primary-node");
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local));

        var snapshot = new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Primary,
                    IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
                new NodeRedundancyState(NodeId.Parse("other-node"), RedundancyRole.Secondary,
                    IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
            },
            CorrelationId.NewId());
        actor.Tell(snapshot);

        AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }),
            duration: PresenceBudget);
    }

    /// <summary>Verifies that RedundancyStateChanged for secondary publishes 100.</summary>
    [Fact]
    public void RedundancyStateChanged_for_secondary_publishes_100()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("secondary-node");
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(serviceLevel: publisher, localNode: local));

        var snapshot = new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Secondary,
                    IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
            },
            CorrelationId.NewId());
        actor.Tell(snapshot);

        AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 100 }),
            duration: PresenceBudget);
    }

    /// <summary>Verifies that the calculator path computes 250 for a healthy primary role-leader
    /// (basis 240 from DB-reachable + probe-ok + fresh, +10 driver-role-leader bonus).</summary>
    [Fact]
    public void Calculator_path_healthy_primary_leader_publishes_250()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("primary-node");
        var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
            new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local, dbHealthProbe: probe));

        actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Primary,
                    IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
            },
            CorrelationId.NewId()));

        AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
            duration: PresenceBudget);
    }

    /// <summary>Verifies that the calculator path computes 240 for a healthy non-leader secondary
    /// (basis 240 from DB-reachable + probe-ok + fresh, no leader bonus). Documented change from the
    /// legacy role-only path, which mapped Secondary → 100.</summary>
    [Fact]
    public void Calculator_path_healthy_secondary_publishes_240()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("secondary-node");
        var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
            new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local, dbHealthProbe: probe));

        actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Secondary,
                    IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
            },
            CorrelationId.NewId()));

        AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
            duration: PresenceBudget);
    }

    /// <summary>Verifies that the calculator path computes 100 when the DB is unreachable
    /// ((false,_,true) basis, stale via !DbReachable) for a non-leader secondary (no bonus).</summary>
    [Fact]
    public void Calculator_path_db_unreachable_publishes_100()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("secondary-node");
        var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
            new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(false, DateTime.UtcNow, "down"))));
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local, dbHealthProbe: probe));

        actor.Tell(new DbHealthProbeActor.DbHealthStatus(false, DateTime.UtcNow, "down"));
        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Secondary,
                    IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
            },
            CorrelationId.NewId()));

        AwaitAssert(() => publisher.Levels.ShouldContain((byte)100),
            duration: PresenceBudget);
    }

    /// <summary>Verifies that the calculator path computes 200 for a stale snapshot when the DB is
    /// reachable + fresh but the redundancy entry's AsOfUtc is older than the stale window
    /// ((true,_,true) basis) for a non-leader secondary (no bonus).</summary>
    [Fact]
    public void Calculator_path_stale_snapshot_publishes_200()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("secondary-node");
        var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
            new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local, dbHealthProbe: probe,
            staleWindow: TimeSpan.FromSeconds(2)));

        actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Secondary,
                    IsClusterLeader: false, IsDriverPrimary: false,
                    DateTime.UtcNow - TimeSpan.FromMinutes(1)),
            },
            CorrelationId.NewId()));

        AwaitAssert(() => publisher.Levels.ShouldContain((byte)200),
            duration: PresenceBudget);
    }

    /// <summary>Verifies that a detached local node publishes 0 (the calculator does not model
    /// Detached, so the handler guards it before the calculator path). The node first goes healthy
    /// (250) so the dedup'd transition down to 0 is observable.</summary>
    [Fact]
    public void Calculator_path_detached_publishes_0()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("detached-node");
        var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
            new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local, dbHealthProbe: probe));

        actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
        // First go healthy primary-leader (250) so the transition down to 0 is not dedup'd against
        // the initial 0.
        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Primary,
                    IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
            },
            CorrelationId.NewId()));
        AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
            duration: PresenceBudget);

        // Now detach — expect the guard to drive ServiceLevel down to 0.
        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Detached,
                    IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
            },
            CorrelationId.NewId()));

        AwaitAssert(() => publisher.Levels.ShouldContain((byte)0),
            duration: PresenceBudget);
    }

    /// <summary>Verifies that an actively-observed, recent peer probe of MY endpoint that came back
    /// <c>Ok==false</c> demotes the calculator basis to 0. A non-leader Secondary entry (no +10 bonus)
    /// is used so the assertion is unambiguous: inputs (DbReachable=true, OpcUaProbeOk=false,
    /// Stale=false) match Compute's <c>_ => 0</c> arm → 0, +0 (non-leader) → 0.</summary>
    [Fact]
    public void Probe_false_about_me_with_healthy_db_publishes_0()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("secondary-node");
        var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
            new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local, dbHealthProbe: probe,
            staleWindow: TimeSpan.FromSeconds(30), probeFreshnessWindow: TimeSpan.FromSeconds(30)));

        actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
        actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: false));
        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Secondary,
                    IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
            },
            CorrelationId.NewId()));

        AwaitAssert(() => publisher.Levels.ShouldContain((byte)0),
            duration: PresenceBudget);
    }

    /// <summary>Verifies branch (3) of <c>OpcUaProbeOk()</c>: a peer's NEGATIVE verdict about this node
    /// that has aged past <c>_probeFreshnessWindow</c> is given the benefit of the doubt (the peer's
    /// verdict aged out → don't demote), so the node still publishes the HEALTHY level. With a 1ms
    /// freshness window and a 30ms wait before the recompute, the cached <c>Ok==false</c> verdict is
    /// reliably stale by the time the level is computed, so <c>OpcUaProbeOk()</c> returns true and a
    /// healthy primary-leader computes inputs (true, true, false) → 240, +10 leader → 250 — NOT the 0
    /// that a still-fresh negative verdict would produce (cf. <see cref="Probe_false_about_me_with_healthy_db_publishes_0"/>).</summary>
    [Fact]
    public void Stale_probe_verdict_is_not_demoted_publishes_healthy()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("primary-node");
        var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
            new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local, dbHealthProbe: probe,
            staleWindow: TimeSpan.FromSeconds(30),
            probeFreshnessWindow: TimeSpan.FromMilliseconds(1)));

        // Seed a healthy DB sample and a NEGATIVE peer verdict about me. (No RedundancyStateChanged yet,
        // so no level is computed from these — the verdict just gets stamped with the receive time.)
        actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
        actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: false));

        // Let the verdict age WELL past the 1ms freshness window before any level is computed. The 30ms
        // wait is >> 1ms, so the verdict is reliably stale by the time OpcUaProbeOk() runs — no reliance
        // on sub-ms clock ties.
        ExpectNoMsg(TimeSpan.FromMilliseconds(30));

        // Now trigger the recompute with a fresh healthy primary-leader snapshot. By now _probeAboutMe is
        // >1ms old, so branch (3) ignores the negative verdict and OpcUaProbeOk() returns true.
        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Primary,
                    IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
            },
            CorrelationId.NewId()));

        // Healthy (250), NOT 0 — proves an aged negative verdict does not demote.
        AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
            duration: PresenceBudget);
    }

    /// <summary>Verifies that with no peer probe result ever received, <c>OpcUaProbeOk()</c> defaults
    /// to <c>true</c> (benefit of the doubt / single-node). A healthy primary-leader thus computes
    /// inputs (true, true, false) → 240, +10 leader → 250.</summary>
    [Fact]
    public void No_probe_result_defaults_ok_true_publishes_250()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("primary-node");
        var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
            new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local, dbHealthProbe: probe,
            staleWindow: TimeSpan.FromSeconds(30), probeFreshnessWindow: TimeSpan.FromSeconds(30)));

        actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Primary,
                    IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
            },
            CorrelationId.NewId()));

        AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
            duration: PresenceBudget);
    }

    /// <summary>Verifies that a later <c>Ok==true</c> peer probe supersedes an earlier <c>Ok==false</c>
    /// (recovery). A non-leader Secondary entry is used (no +10 bonus): after the true supersedes,
    /// inputs (true, true, false) → 240, +0 → 240.</summary>
    [Fact]
    public void Probe_true_supersedes_earlier_false_publishes_240()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("secondary-node");
        var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
            new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local, dbHealthProbe: probe,
            staleWindow: TimeSpan.FromSeconds(30), probeFreshnessWindow: TimeSpan.FromSeconds(30)));

        actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
        actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: false));
        actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: true));
        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Secondary,
                    IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
            },
            CorrelationId.NewId()));

        AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
            duration: PresenceBudget);
    }

    /// <summary>Verifies that a peer probe result about a DIFFERENT node is ignored — it does not
    /// affect MY <c>OpcUaProbeOk()</c>, which stays at its default <c>true</c>. A healthy
    /// primary-leader thus still computes (true, true, false) → 240, +10 → 250.</summary>
    [Fact]
    public void Probe_about_a_different_node_is_ignored()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("primary-node");
        var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
            new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local, dbHealthProbe: probe,
            staleWindow: TimeSpan.FromSeconds(30), probeFreshnessWindow: TimeSpan.FromSeconds(30)));

        actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
        actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(NodeId.Parse("someone-else"), Ok: false));
        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Primary,
                    IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
            },
            CorrelationId.NewId()));

        AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
            duration: PresenceBudget);
    }

    /// <summary>Verifies the legacy back-compat seam: with no DB-health probe wired, the handler
    /// falls back to the old role-only switch (Primary + leader → 240).</summary>
    [Fact]
    public void Legacy_path_no_db_probe_keeps_role_only()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("primary-node");
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local));

        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Primary,
                    IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
            },
            CorrelationId.NewId()));

        AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
            duration: PresenceBudget);
    }

    /// <summary>Verifies that the periodic <c>HealthTick</c> Asks the local <see cref="DbHealthProbeActor"/>
    /// for status and pipes the reply back to itself — populating <c>_lastDbHealth</c> in production WITHOUT
    /// any external pump (no direct <see cref="DbHealthProbeActor.DbHealthStatus"/> Tell). With a healthy DB
    /// reply and a primary-leader snapshot, the calculator path therefore reaches 250.</summary>
    [Fact]
    public void HealthTick_asks_db_probe_and_publishes_calculator_byte()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("primary-node");
        var db = Sys.ActorOf(Akka.Actor.Props.Create(() => new StubDbHealth(
            new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local, dbHealthProbe: db,
            staleWindow: TimeSpan.FromSeconds(30), probeFreshnessWindow: TimeSpan.FromSeconds(30),
            healthTickInterval: TimeSpan.FromMilliseconds(100)));
        actor.Tell(new RedundancyStateChanged(new[]
        {
            new NodeRedundancyState(local, RedundancyRole.Primary,
                IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
        }, CorrelationId.NewId()));
        // No direct DbHealthStatus Tell — the periodic HealthTick Ask must populate it → 250.
        AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), TimeSpan.FromSeconds(2));
    }

    // ── Per-cluster mesh Phase 4: DB-less driver-only nodes ─────────────────────────────────────
    // A driver-only node has no ConfigDb (LocalDb is its config store), so there is no DB to probe.
    // Such nodes run with dbHealthProbe: null + dbLess: true and must still publish the full
    // survive-alone ServiceLevel (240 follower / 250 Primary) — DbReachable is treated as a
    // constant true because the in-process LocalDb can never be "unreachable"; only snapshot
    // staleness demotes.

    /// <summary>DB-less, member Up, snapshot fresh, follower (non-Primary) → 240. No probe wired,
    /// no DbHealthStatus ever arrives; the DB-less branch must still reach full service.</summary>
    [Fact]
    public void DbLess_healthy_follower_publishes_240()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("db-less-follower");
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local, dbLess: true,
            staleWindow: TimeSpan.FromSeconds(30)));

        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Secondary,
                    IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
            },
            CorrelationId.NewId()));

        AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
            duration: PresenceBudget);
    }

    /// <summary>DB-less, member Up, snapshot fresh, Primary → 250 (basis 240 + 10 Primary bonus).</summary>
    [Fact]
    public void DbLess_healthy_primary_publishes_250()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("db-less-primary");
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local, dbLess: true,
            staleWindow: TimeSpan.FromSeconds(30)));

        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Primary,
                    IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
            },
            CorrelationId.NewId()));

        AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
            duration: PresenceBudget);
    }

    /// <summary>DB-less, snapshot stale (entry.AsOfUtc older than the stale window) → 200. Staleness
    /// is derived from snapshot age ALONE (no DB-health term), so a DB-less node still demotes to 200
    /// when it is running behind on config.</summary>
    [Fact]
    public void DbLess_stale_snapshot_publishes_200()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("db-less-stale");
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local, dbLess: true,
            staleWindow: TimeSpan.FromSeconds(2)));

        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Secondary,
                    IsClusterLeader: false, IsDriverPrimary: false,
                    DateTime.UtcNow - TimeSpan.FromMinutes(1)),
            },
            CorrelationId.NewId()));

        AwaitAssert(() => publisher.Levels.ShouldContain((byte)200),
            duration: PresenceBudget);
    }

    /// <summary>DB-less, Detached → 0. The Detached / missing-entry guard runs ABOVE the DB-less
    /// branch, so a DB-less node that detaches still fails closed to 0. Goes healthy (250) first so
    /// the transition down to 0 is observable (not dedup'd against the initial 0).</summary>
    [Fact]
    public void DbLess_detached_publishes_0()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("db-less-detached");
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local, dbLess: true,
            staleWindow: TimeSpan.FromSeconds(30)));

        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Primary,
                    IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
            },
            CorrelationId.NewId()));
        AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
            duration: PresenceBudget);

        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Detached,
                    IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
            },
            CorrelationId.NewId()));

        AwaitAssert(() => publisher.Levels.ShouldContain((byte)0),
            duration: PresenceBudget);
    }

    /// <summary>Regression: a DB-BACKED node (probe wired + DbHealthStatus reachable, dbLess default
    /// false) is unchanged — it still computes 240 via the calculator path. Proves the new DB-less
    /// branch did not disturb the existing DB-backed seam.</summary>
    [Fact]
    public void DbBacked_node_unchanged_still_publishes_240()
    {
        var publisher = new RecordingPublisher();
        var local = NodeId.Parse("db-backed-node");
        var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
            new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            serviceLevel: publisher, localNode: local, dbHealthProbe: probe));

        actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
        actor.Tell(new RedundancyStateChanged(
            Nodes: new[]
            {
                new NodeRedundancyState(local, RedundancyRole.Secondary,
                    IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
            },
            CorrelationId.NewId()));

        AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
            duration: PresenceBudget);
    }

    /// <summary>Stub DB-health probe actor that answers <see cref="DbHealthProbeActor.GetStatus"/>
    /// with a fixed status, so the calculator path is deterministic without a real timer.</summary>
    private sealed class StubDbHealth : Akka.Actor.ReceiveActor
    {
        /// <summary>Initializes a new instance of the <see cref="StubDbHealth"/> class.</summary>
        /// <param name="status">The fixed DB-health status to reply with.</param>
        public StubDbHealth(DbHealthProbeActor.DbHealthStatus status) =>
            Receive<DbHealthProbeActor.GetStatus>(_ => Sender.Tell(status));
    }

    /// <summary>Test implementation of IOpcUaAddressSpaceSink that records calls.</summary>
    private sealed class RecordingSink : IOpcUaAddressSpaceSink
    {
        /// <summary>Gets the queue of recorded value updates.</summary>
        public ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> ValueQueue { get; } = new();
        /// <summary>Gets the queue of recorded alarm condition updates.</summary>
        public ConcurrentQueue<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> AlarmQueue { get; } = new();
        /// <summary>Gets the queue of recorded alarm-quality annotations (#477).</summary>
        public ConcurrentQueue<(string AlarmNodeId, OpcUaQuality Quality, DateTime Ts, AddressSpaceRealm Realm)> AlarmQualityQueue { get; } = new();
        /// <summary>Count of rebuild calls.</summary>
        public int RebuildCalls;
        /// <summary>Gets the queue of recorded EnsureFolder calls.</summary>
        public ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName)> FolderQueue { get; } = new();
        /// <summary>Gets the queue of recorded EnsureVariable calls.</summary>
        public ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> VariableQueue { get; } = new();
        /// <summary>Gets the queue of recorded RaiseNodesAddedModelChange announcements.</summary>
        public ConcurrentQueue<string> ModelChangeQueue { get; } = new();

        /// <summary>Gets the list of recorded value updates.</summary>
        public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values =>
            ValueQueue.ToList();
        /// <summary>Gets the list of recorded alarm condition updates.</summary>
        public List<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> Alarms =>
            AlarmQueue.ToList();
        /// <summary>Gets the list of recorded EnsureFolder calls.</summary>
        public List<(string NodeId, string? ParentNodeId, string DisplayName)> Folders =>
            FolderQueue.ToList();
        /// <summary>Gets the list of recorded EnsureVariable calls.</summary>
        public List<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> Variables =>
            VariableQueue.ToList();
        /// <summary>Gets the list of recorded RaiseNodesAddedModelChange announcements.</summary>
        public List<string> ModelChanges => ModelChangeQueue.ToList();

        /// <summary>Records a value update.</summary>
        /// <param name="nodeId">The OPC UA node identifier.</param>
        /// <param name="value">The attribute value.</param>
        /// <param name="quality">The OPC UA quality code.</param>
        /// <param name="ts">The timestamp of the update.</param>
        public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
            ValueQueue.Enqueue((nodeId, value, quality, ts));

        /// <summary>Records an alarm condition update.</summary>
        /// <param name="alarmNodeId">The OPC UA alarm node identifier.</param>
        /// <param name="state">The full condition state snapshot.</param>
        /// <param name="ts">The timestamp of the update.</param>
        public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
            AlarmQueue.Enqueue((alarmNodeId, state, ts));

        /// <summary>Records an alarm-quality annotation (#477).</summary>
        public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
            AlarmQualityQueue.Enqueue((alarmNodeId, quality, ts, realm));

        /// <summary>Materialises an alarm condition (no-op in test).</summary>
        /// <param name="alarmNodeId">The alarm node ID.</param>
        /// <param name="equipmentNodeId">The equipment folder node ID.</param>
        /// <param name="displayName">The condition display name.</param>
        /// <param name="alarmType">The domain alarm type.</param>
        /// <param name="severity">The domain severity.</param>
        public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }

        /// <summary>Records a folder ensure call.</summary>
        /// <param name="folderNodeId">The OPC UA folder node identifier.</param>
        /// <param name="parentNodeId">The parent folder node identifier, or null for root.</param>
        /// <param name="displayName">The display name of the folder.</param>
        public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
            FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));

        /// <summary>Records a variable ensure call.</summary>
        /// <param name="variableNodeId">The OPC UA variable node identifier.</param>
        /// <param name="parentFolderNodeId">The parent folder node identifier, or null for root.</param>
        /// <param name="displayName">The display name of the variable.</param>
        /// <param name="dataType">The OPC UA built-in type name.</param>
        /// <param name="writable">Whether the node is created read/write.</param>
        /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
        public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) =>
            VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));

        /// <summary>Records a rebuild call.</summary>
        public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);

        /// <summary>Records a NodeAdded model-change announcement.</summary>
        /// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
        public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId);
        public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
        public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
    }

    /// <summary>Test implementation of IServiceLevelPublisher that records publishes.</summary>
    private sealed class RecordingPublisher : IServiceLevelPublisher
    {
        private readonly ConcurrentQueue<byte> _q = new();
        /// <summary>Gets the recorded service levels.</summary>
        public byte[] Levels => _q.ToArray();
        /// <summary>Records a service level publish.</summary>
        /// <param name="serviceLevel">The service level value to publish.</param>
        public void Publish(byte serviceLevel) => _q.Enqueue(serviceLevel);
    }
}
