using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;

namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;

/// <summary>
/// Tests for DebugStreamBridgeActor with gRPC streaming integration.
/// </summary>
/// <remarks>
/// Shares the <c>DebugStreamStatics</c> xUnit collection with <c>DebugStreamServiceTests</c>:
/// both tune the actor's static test seams (<c>SnapshotTimeout</c>, <c>ConsumerIdleTimeout</c>,
/// …), and xUnit parallelizes distinct classes by default, which would let one class's
/// try/finally restore clobber the other's window mid-test.
/// </remarks>
[Collection("DebugStreamStatics")]
public class DebugStreamBridgeActorTests : TestKit
{
    private const string SiteId = "site-alpha";
    private const string InstanceName = "Site1.Pump01";
    private const string GrpcNodeA = "http://localhost:5100";
    private const string GrpcNodeB = "http://localhost:5200";

    public DebugStreamBridgeActorTests() : base(@"akka.loglevel = DEBUG")
    {
        // Use a very short reconnect delay for testing
        DebugStreamBridgeActor.ReconnectDelay = TimeSpan.FromMilliseconds(100);
        // Long stability window so streams are never considered "stable" mid-test
        // unless a test deliberately waits it out.
        DebugStreamBridgeActor.StabilityWindow = TimeSpan.FromSeconds(30);
    }

    private record TestContext(
        IActorRef BridgeActor,
        TestProbe CommProbe,
        MockSiteStreamGrpcClient MockGrpcClient,
        List<object> ReceivedEvents,
        bool[] TerminatedFlag);

    private TestContext CreateBridgeActor()
    {
        var commProbe = CreateTestProbe();
        var mockClient = new MockSiteStreamGrpcClient();
        var factory = new MockSiteStreamGrpcClientFactory(mockClient);
        var events = new List<object>();
        var terminated = new[] { false };

        Action<object> onEvent = evt => { lock (events) { events.Add(evt); } };
        Action onTerminated = () => terminated[0] = true;

        var props = Props.Create(typeof(DebugStreamBridgeActor),
            SiteId,
            InstanceName,
            "corr-1",
            commProbe.Ref,
            onEvent,
            onTerminated,
            factory,
            GrpcNodeA,
            GrpcNodeB);

        var actor = Sys.ActorOf(props);
        return new TestContext(actor, commProbe, mockClient, events, terminated);
    }

    [Fact]
    public void On_InstanceNotFound_Snapshot_Forwards_To_OnEvent_Tears_Down_Stream_And_Terminates()
    {
        // M2.11 (revised for M2.18 stream-first): the gRPC subscription is now opened
        // up-front in PreStart, so when the site reports InstanceNotFound=true the
        // bridge actor must
        // (a) forward the not-found snapshot to _onEvent so DebugStreamService's TCS
        //     resolves and the caller can inspect the flag,
        // (b) tear DOWN the already-opened gRPC stream (Unsubscribe the just-opened
        //     correlation) rather than enter pass-through, and
        // (c) stop itself cleanly.
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>(); // initial subscribe envelope

        // Stream-first: the gRPC subscription is opened before the snapshot arrives.
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        var notFoundSnapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            DateTimeOffset.UtcNow,
            InstanceNotFound: true);

        Watch(ctx.BridgeActor);
        ctx.BridgeActor.Tell(notFoundSnapshot);

        // (a) _onEvent must receive the not-found snapshot
        AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 1; } },
            TimeSpan.FromSeconds(3));
        lock (ctx.ReceivedEvents)
        {
            var received = Assert.IsType<DebugViewSnapshot>(ctx.ReceivedEvents[0]);
            Assert.True(received.InstanceNotFound);
        }

        // (b) the just-opened gRPC stream is torn down (not left running / no pass-through)
        AwaitCondition(() => ctx.MockGrpcClient.UnsubscribedCorrelationIds.Contains("corr-1"),
            TimeSpan.FromSeconds(3));

        // (c) actor terminates cleanly
        ExpectTerminated(ctx.BridgeActor, TimeSpan.FromSeconds(3));
    }

    [Fact]
    public void PreStart_Sends_SubscribeDebugViewRequest_Via_ClusterClient()
    {
        var ctx = CreateBridgeActor();

        var envelope = ctx.CommProbe.ExpectMsg<SiteEnvelope>();
        Assert.Equal(SiteId, envelope.SiteId);
        Assert.IsType<SubscribeDebugViewRequest>(envelope.Message);

        var req = (SubscribeDebugViewRequest)envelope.Message;
        Assert.Equal(InstanceName, req.InstanceUniqueName);
        Assert.Equal("corr-1", req.CorrelationId);
    }

    [Fact]
    public void On_Snapshot_Forwards_To_OnEvent_Callback()
    {
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();

        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            DateTimeOffset.UtcNow);

        ctx.BridgeActor.Tell(snapshot);

        AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 1; } },
            TimeSpan.FromSeconds(3));
        lock (ctx.ReceivedEvents) { Assert.IsType<DebugViewSnapshot>(ctx.ReceivedEvents[0]); }
    }

    [Fact]
    public void On_Snapshot_Does_Not_Open_Additional_GrpcStream()
    {
        // M2.18 stream-first: the gRPC subscription is opened in PreStart, BEFORE the
        // snapshot arrives. After the snapshot is delivered the actor switches to
        // pass-through — it must NOT open a second subscription. Exactly ONE subscribe
        // call should have been made (the PreStart one).
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();

        // Verify the stream is already open before the snapshot.
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            DateTimeOffset.UtcNow);

        ctx.BridgeActor.Tell(snapshot);

        // After snapshot delivery, still exactly ONE subscribe — no additional stream opened.
        AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 1; } },
            TimeSpan.FromSeconds(3));
        var singleCall = Assert.Single(ctx.MockGrpcClient.SubscribeCalls);
        Assert.Equal("corr-1", singleCall.CorrelationId);
        Assert.Equal(InstanceName, singleCall.InstanceUniqueName);
    }

    [Fact]
    public void Events_From_GrpcCallback_Forwarded_To_OnEvent()
    {
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();

        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            DateTimeOffset.UtcNow);

        ctx.BridgeActor.Tell(snapshot);
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        // Simulate gRPC event arriving via the onEvent callback
        var attrChange = new AttributeValueChanged(InstanceName, "IO", "Temp", 42.5, "Good", DateTimeOffset.UtcNow);
        ctx.MockGrpcClient.SubscribeCalls[0].OnEvent(attrChange);

        // snapshot + attr change
        AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 2; } },
            TimeSpan.FromSeconds(3));
        lock (ctx.ReceivedEvents) { Assert.IsType<AttributeValueChanged>(ctx.ReceivedEvents[1]); }
    }

    [Fact]
    public void On_GrpcError_Reconnects_To_Other_Node()
    {
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();

        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            DateTimeOffset.UtcNow);

        ctx.BridgeActor.Tell(snapshot);
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        // Simulate gRPC error
        ctx.MockGrpcClient.SubscribeCalls[0].OnError(new Exception("Stream broken"));

        // Should resubscribe
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 2, TimeSpan.FromSeconds(5));
        Assert.Equal("corr-1", ctx.MockGrpcClient.SubscribeCalls[1].CorrelationId);
    }

    [Fact]
    public void On_GrpcError_Unsubscribes_Old_Stream_Before_Reconnect()
    {
        // Communication-002 regression: a reconnect must unsubscribe the previous
        // stream so the old node does not keep a zombie relay actor / subscription.
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();

        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            DateTimeOffset.UtcNow);

        ctx.BridgeActor.Tell(snapshot);
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        // Simulate gRPC error → reconnect
        ctx.MockGrpcClient.SubscribeCalls[0].OnError(new Exception("Stream broken"));

        // Should resubscribe...
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 2, TimeSpan.FromSeconds(5));

        // ...and must have unsubscribed the prior correlation ID so the old node's
        // relay actor is released rather than left zombie.
        Assert.Contains("corr-1", ctx.MockGrpcClient.UnsubscribedCorrelationIds);
    }

    [Fact]
    public void After_MaxRetries_Terminates()
    {
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();

        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            DateTimeOffset.UtcNow);

        Watch(ctx.BridgeActor);
        ctx.BridgeActor.Tell(snapshot);
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        // 4 consecutive errors: initial + 3 retries, then terminate
        ctx.MockGrpcClient.SubscribeCalls[0].OnError(new Exception("Error 1"));
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 2, TimeSpan.FromSeconds(5));

        ctx.MockGrpcClient.SubscribeCalls[1].OnError(new Exception("Error 2"));
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 3, TimeSpan.FromSeconds(5));

        ctx.MockGrpcClient.SubscribeCalls[2].OnError(new Exception("Error 3"));
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 4, TimeSpan.FromSeconds(5));

        // Fourth error exceeds max retries
        ctx.MockGrpcClient.SubscribeCalls[3].OnError(new Exception("Error 4"));

        ExpectTerminated(ctx.BridgeActor, TimeSpan.FromSeconds(5));
        Assert.True(ctx.TerminatedFlag[0]);
    }

    [Fact]
    public void StopDebugStream_Cancels_Grpc_And_Sends_Unsubscribe()
    {
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>(); // subscribe

        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            DateTimeOffset.UtcNow);

        Watch(ctx.BridgeActor);
        ctx.BridgeActor.Tell(snapshot);
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        ctx.BridgeActor.Tell(new StopDebugStream());

        // Should send the site-addressed unsubscribe
        var envelope = ctx.CommProbe.ExpectMsg<SiteEnvelope>();
        Assert.IsType<UnsubscribeDebugViewRequest>(envelope.Message);

        // Should unsubscribe gRPC
        AwaitCondition(() => ctx.MockGrpcClient.UnsubscribedCorrelationIds.Count > 0, TimeSpan.FromSeconds(3));
        Assert.Contains("corr-1", ctx.MockGrpcClient.UnsubscribedCorrelationIds);

        // Should stop self
        ExpectTerminated(ctx.BridgeActor);
    }

    [Fact]
    public void DebugStreamTerminated_Stops_Actor_Idempotently()
    {
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();

        Watch(ctx.BridgeActor);
        ctx.BridgeActor.Tell(new DebugStreamTerminated(SiteId, "corr-1"));

        ExpectTerminated(ctx.BridgeActor);
        Assert.True(ctx.TerminatedFlag[0]);
    }

    [Fact]
    public void FlappingStream_DeliveringEventsBetweenFailures_StillTerminatesAfterMaxRetries()
    {
        // Communication-008 regression: a stream that connects, delivers an event,
        // then fails — repeatedly — must still trip MaxRetries. The retry count is
        // NO LONGER reset by a received event (only by the stability window). The
        // previous behaviour reset _retryCount on every event, so a flapping site
        // reconnected forever and the debug session lived on indefinitely.
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();

        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            DateTimeOffset.UtcNow);

        Watch(ctx.BridgeActor);
        ctx.BridgeActor.Tell(snapshot);
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        var attrChange = new AttributeValueChanged(InstanceName, "IO", "Temp", 42.5, "Good", DateTimeOffset.UtcNow);

        // Flap: deliver one event then fail, three times. Each event would, under
        // the old buggy logic, reset the retry budget and prevent termination.
        for (var i = 0; i < 3; i++)
        {
            var call = ctx.MockGrpcClient.SubscribeCalls[i];
            call.OnEvent(attrChange);
            call.OnError(new Exception($"Flap {i + 1}"));
            var expected = i + 2;
            AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == expected, TimeSpan.FromSeconds(5));
        }

        // Fourth error (after the 3 retries) must exceed MaxRetries and terminate.
        ctx.MockGrpcClient.SubscribeCalls[3].OnEvent(attrChange);
        ctx.MockGrpcClient.SubscribeCalls[3].OnError(new Exception("Flap 4"));

        ExpectTerminated(ctx.BridgeActor, TimeSpan.FromSeconds(5));
        Assert.True(ctx.TerminatedFlag[0]);
    }

    [Fact]
    public void On_GrpcError_Reconnects_To_Other_Node_Endpoint()
    {
        // Communication-015 regression: drive the bridge actor through a node flip
        // with an endpoint-aware factory (one distinct mock client per endpoint).
        // The first subscribe targets NodeA; after a gRPC error the bridge must
        // reconnect via a client bound to the *NodeB* endpoint.
        var commProbe = CreateTestProbe();
        var factory = new EndpointTrackingGrpcClientFactory();
        var events = new List<object>();
        var terminated = new[] { false };

        var props = Props.Create(typeof(DebugStreamBridgeActor),
            SiteId, InstanceName, "corr-1", commProbe.Ref,
            (Action<object>)(evt => { lock (events) { events.Add(evt); } }),
            (Action)(() => terminated[0] = true),
            factory, GrpcNodeA, GrpcNodeB);

        var actor = Sys.ActorOf(props);
        commProbe.ExpectMsg<SiteEnvelope>();

        actor.Tell(new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            DateTimeOffset.UtcNow));

        // First subscribe goes to NodeA.
        AwaitCondition(() => factory.ClientFor(GrpcNodeA).SubscribeCalls.Count == 1,
            TimeSpan.FromSeconds(3));

        // gRPC error → bridge flips to NodeB.
        factory.ClientFor(GrpcNodeA).SubscribeCalls[0].OnError(new Exception("NodeA down"));

        // The reconnect must reach a client bound to the NodeB endpoint.
        AwaitCondition(() => factory.ClientFor(GrpcNodeB).SubscribeCalls.Count == 1,
            TimeSpan.FromSeconds(5));
        Assert.Equal("corr-1", factory.ClientFor(GrpcNodeB).SubscribeCalls[0].CorrelationId);
    }

    // ── WP1.1: graceful (status OK) stream completion is a reconnect trigger ──

    [Fact]
    public void On_GracefulStreamCompletion_Reopens_On_The_Same_Node()
    {
        // The site caps every stream at GrpcMaxStreamLifetime and then ends the RPC with
        // OK. Pre-fix the client's read loop just finished, the actor was told nothing, and
        // the debug session went silently deaf for the rest of its life.
        var (_, factory) = CreateBridgeWithTrackingFactory();
        AwaitCondition(() => factory.ClientFor(GrpcNodeA).SubscribeCalls.Count == 1,
            TimeSpan.FromSeconds(3));

        factory.ClientFor(GrpcNodeA).SubscribeCalls[0].OnCompleted();

        // Reopened on the SAME node — a clean close is not a fault, so there is nothing to
        // fail over from — and the finished stream is released, not left zombie.
        AwaitCondition(() => factory.ClientFor(GrpcNodeA).SubscribeCalls.Count == 2,
            TimeSpan.FromSeconds(5));
        Assert.Empty(factory.ClientFor(GrpcNodeB).SubscribeCalls);
        Assert.Contains("corr-1", factory.ClientFor(GrpcNodeA).UnsubscribedCorrelationIds);
    }

    [Fact]
    public void RepeatedGracefulCompletions_DoNotConsume_TheErrorRetryBudget()
    {
        // Five completions — two more than MaxRetries. Had completion been routed through
        // the error path the session would have terminated on the fourth.
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        for (var i = 1; i <= 5; i++)
        {
            ctx.MockGrpcClient.SubscribeCalls[i - 1].OnCompleted();
            var expected = i + 1;
            AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == expected,
                TimeSpan.FromSeconds(5));
        }

        Assert.False(ctx.TerminatedFlag[0]);
    }

    [Fact]
    public void LateCompletionFromAPreviousStreamGeneration_IsIgnored()
    {
        var (_, factory) = CreateBridgeWithTrackingFactory();
        AwaitCondition(() => factory.ClientFor(GrpcNodeA).SubscribeCalls.Count == 1,
            TimeSpan.FromSeconds(3));

        var firstSub = factory.ClientFor(GrpcNodeA).SubscribeCalls[0];
        firstSub.OnError(new Exception("NodeA down"));   // gen 1 dies → gen 2 opens on NodeB
        AwaitCondition(() => factory.ClientFor(GrpcNodeB).SubscribeCalls.Count == 1,
            TimeSpan.FromSeconds(5));

        // A completion racing out of the dead gen-1 stream must not tear down the live one.
        firstSub.OnCompleted();
        Thread.Sleep(300);
        Assert.DoesNotContain("corr-1", factory.ClientFor(GrpcNodeB).UnsubscribedCorrelationIds);
        Assert.Single(factory.ClientFor(GrpcNodeB).SubscribeCalls);
    }

    // ── Task 6 (arch review 02, High): teardown/failover unsubscribe is endpoint-safe ──
    // Both paths use TryGet, never GetOrCreate, so cleanup can never open a fresh
    // channel or (with (site,endpoint) keying) touch another session's channel.

    private (IActorRef Actor, EndpointTrackingGrpcClientFactory Factory) CreateBridgeWithTrackingFactory()
    {
        var commProbe = CreateTestProbe();
        var factory = new EndpointTrackingGrpcClientFactory();
        var props = Props.Create(typeof(DebugStreamBridgeActor),
            SiteId, InstanceName, "corr-1", commProbe.Ref,
            (Action<object>)(_ => { }), (Action)(() => { }),
            factory, GrpcNodeA, GrpcNodeB);
        var actor = Sys.ActorOf(props);
        commProbe.ExpectMsg<SiteEnvelope>(); // PreStart subscribe handshake
        return (actor, factory);
    }

    [Fact]
    public void CleanupGrpc_UnsubscribesViaTryGet_WithoutOpeningAChannel()
    {
        var (actor, factory) = CreateBridgeWithTrackingFactory();
        AwaitCondition(() => factory.ClientFor(GrpcNodeA).SubscribeCalls.Count == 1,
            TimeSpan.FromSeconds(3));
        var createdBefore = factory.CreatedCount; // node-a only

        actor.Tell(new StopDebugStream());

        AwaitCondition(() => factory.ClientFor(GrpcNodeA).UnsubscribedCorrelationIds.Contains("corr-1"),
            TimeSpan.FromSeconds(3));
        // Pre-fix CleanupGrpc called GetOrCreate → could open a fresh channel here.
        Assert.Equal(createdBefore, factory.CreatedCount);
    }

    [Fact]
    public void SessionFailover_UnsubscribesFailedEndpointViaTryGet_OpensOnlyTheReconnectChannel()
    {
        var (_, factory) = CreateBridgeWithTrackingFactory();
        AwaitCondition(() => factory.ClientFor(GrpcNodeA).SubscribeCalls.Count == 1,
            TimeSpan.FromSeconds(3));

        // Fail NodeA: the bridge unsubscribes the failed NodeA stream via TryGet
        // (if that path had used GetOrCreate on the post-flip cache it could have
        // disposed/created the wrong client), then reconnects to NodeB.
        factory.ClientFor(GrpcNodeA).SubscribeCalls[0].OnError(new Exception("NodeA down"));

        AwaitCondition(() => factory.ClientFor(GrpcNodeA).UnsubscribedCorrelationIds.Contains("corr-1"),
            TimeSpan.FromSeconds(5));
        AwaitCondition(() => factory.ClientFor(GrpcNodeB).SubscribeCalls.Count == 1,
            TimeSpan.FromSeconds(5));
        // Only the two real node endpoints were ever opened — the unsubscribe
        // itself created nothing.
        Assert.Equal(2, factory.CreatedCount);
    }

    // ---------------------------------------------------------------------
    // M2.18 (#26) — stream-first + replay/dedup
    // ---------------------------------------------------------------------

    [Fact]
    public void PreStart_Opens_GrpcStream_Before_Snapshot_Arrives()
    {
        // M2.18: the gRPC subscription must be opened in PreStart (stream-first),
        // BEFORE the snapshot is delivered, so live events start flowing during the
        // snapshot-build + network-transit window. The old lifecycle opened the
        // stream only after the snapshot arrived, losing gap-window events.
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>(); // initial subscribe envelope

        // No snapshot sent yet — the stream must already be open.
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));
        Assert.Equal("corr-1", ctx.MockGrpcClient.SubscribeCalls[0].CorrelationId);
        Assert.Equal(InstanceName, ctx.MockGrpcClient.SubscribeCalls[0].InstanceUniqueName);

        // _onEvent must NOT have fired — buffering, not delivering.
        lock (ctx.ReceivedEvents) { Assert.Empty(ctx.ReceivedEvents); }
    }

    [Fact]
    public void GapWindow_Event_Buffered_Before_Snapshot_Is_Delivered_Exactly_Once_After_Snapshot()
    {
        // M2.18: an event arriving DURING the snapshot window (before the snapshot
        // is delivered) is buffered, then flushed exactly once AFTER the snapshot.
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        // Live event arrives BEFORE the snapshot — its entity is NOT in the snapshot,
        // so it is a genuine gap-window event that must survive.
        var gapEvent = new AttributeValueChanged(InstanceName, "IO", "Pressure", 99.9, "Good",
            DateTimeOffset.UtcNow);
        ctx.MockGrpcClient.SubscribeCalls[0].OnEvent(gapEvent);

        // While buffering, _onEvent has not fired.
        lock (ctx.ReceivedEvents) { Assert.Empty(ctx.ReceivedEvents); }

        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            DateTimeOffset.UtcNow);
        ctx.BridgeActor.Tell(snapshot);

        // snapshot then the buffered gap-window event, exactly once, in that order.
        AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 2; } },
            TimeSpan.FromSeconds(3));
        lock (ctx.ReceivedEvents)
        {
            Assert.IsType<DebugViewSnapshot>(ctx.ReceivedEvents[0]);
            var flushed = Assert.IsType<AttributeValueChanged>(ctx.ReceivedEvents[1]);
            Assert.Equal("Pressure", flushed.AttributeName);
        }
    }

    [Fact]
    public void Buffered_Event_Already_Reflected_In_Snapshot_Is_Dropped()
    {
        // M2.18 dedup: a buffered event whose entity is in the snapshot with an equal
        // or newer snapshot timestamp (buffered.Timestamp <= snapshot.Timestamp) is
        // already reflected and must be DROPPED.
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        var t0 = DateTimeOffset.UtcNow;

        // Buffered event for "Temp" at t0.
        var buffered = new AttributeValueChanged(InstanceName, "IO", "Temp", 42.5, "Good", t0);
        ctx.MockGrpcClient.SubscribeCalls[0].OnEvent(buffered);

        // Snapshot already contains "Temp" at the SAME timestamp t0 → buffered is a dup.
        var snapAttr = new AttributeValueChanged(InstanceName, "IO", "Temp", 42.5, "Good", t0);
        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged> { snapAttr },
            new List<AlarmStateChanged>(),
            t0);
        ctx.BridgeActor.Tell(snapshot);

        // Only the snapshot is delivered; the buffered duplicate is dropped.
        AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 1; } },
            TimeSpan.FromSeconds(3));
        // Give a beat to ensure no extra (dropped) event sneaks through.
        Thread.Sleep(200);
        lock (ctx.ReceivedEvents)
        {
            Assert.Single(ctx.ReceivedEvents);
            Assert.IsType<DebugViewSnapshot>(ctx.ReceivedEvents[0]);
        }
    }

    [Fact]
    public void Buffered_Event_Strictly_Newer_Than_Snapshot_Entity_Is_Delivered()
    {
        // M2.18 dedup: a buffered event strictly newer than the snapshot's entry for
        // the same entity (buffered.Timestamp > snapshot.Timestamp) is NOT a dup and
        // must be DELIVERED after the snapshot.
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        var snapTime = DateTimeOffset.UtcNow;
        var newerTime = snapTime.AddMilliseconds(1);

        // Buffered event for "Temp" strictly NEWER than the snapshot's "Temp".
        var buffered = new AttributeValueChanged(InstanceName, "IO", "Temp", 50.0, "Good", newerTime);
        ctx.MockGrpcClient.SubscribeCalls[0].OnEvent(buffered);

        var snapAttr = new AttributeValueChanged(InstanceName, "IO", "Temp", 42.5, "Good", snapTime);
        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged> { snapAttr },
            new List<AlarmStateChanged>(),
            snapTime);
        ctx.BridgeActor.Tell(snapshot);

        // snapshot then the strictly-newer buffered event.
        AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 2; } },
            TimeSpan.FromSeconds(3));
        lock (ctx.ReceivedEvents)
        {
            Assert.IsType<DebugViewSnapshot>(ctx.ReceivedEvents[0]);
            var flushed = Assert.IsType<AttributeValueChanged>(ctx.ReceivedEvents[1]);
            Assert.Equal(50.0, flushed.Value);
            Assert.Equal(newerTime, flushed.Timestamp);
        }
    }

    [Fact]
    public void Buffered_Alarm_Dedup_Uses_AlarmIdentity_And_Timestamp()
    {
        // M2.18 dedup for alarms: identity = (instance, alarm name, source reference).
        // A buffered alarm older-or-equal to the snapshot's same-identity alarm is
        // dropped; a strictly-newer one is delivered.
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        var t0 = DateTimeOffset.UtcNow;

        // Buffered: "PumpFault" at t0 (dup) and "Overheat" at t0+1ms (newer, deliver).
        var dupAlarm = new AlarmStateChanged(InstanceName, "PumpFault",
            ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmState.Active, 500, t0);
        var newerAlarm = new AlarmStateChanged(InstanceName, "Overheat",
            ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmState.Active, 700, t0.AddMilliseconds(1));
        ctx.MockGrpcClient.SubscribeCalls[0].OnEvent(dupAlarm);
        ctx.MockGrpcClient.SubscribeCalls[0].OnEvent(newerAlarm);

        // Snapshot contains BOTH "PumpFault" and "Overheat" at t0.
        var snapPumpFault = new AlarmStateChanged(InstanceName, "PumpFault",
            ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmState.Active, 500, t0);
        var snapOverheat = new AlarmStateChanged(InstanceName, "Overheat",
            ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmState.Normal, 0, t0);
        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged> { snapPumpFault, snapOverheat },
            t0);
        ctx.BridgeActor.Tell(snapshot);

        // snapshot + only the strictly-newer "Overheat" alarm (PumpFault dropped).
        AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 2; } },
            TimeSpan.FromSeconds(3));
        Thread.Sleep(200);
        lock (ctx.ReceivedEvents)
        {
            Assert.Equal(2, ctx.ReceivedEvents.Count);
            Assert.IsType<DebugViewSnapshot>(ctx.ReceivedEvents[0]);
            var flushed = Assert.IsType<AlarmStateChanged>(ctx.ReceivedEvents[1]);
            Assert.Equal("Overheat", flushed.AlarmName);
            Assert.Equal(700, flushed.Priority);
        }
    }

    [Fact]
    public void Buffered_Events_Flushed_In_Arrival_Order()
    {
        // M2.18: ordering preserved across multiple buffered events (none are dups —
        // their entities are absent from the snapshot).
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        var baseTime = DateTimeOffset.UtcNow;
        var sub = ctx.MockGrpcClient.SubscribeCalls[0];
        sub.OnEvent(new AttributeValueChanged(InstanceName, "IO", "A", 1, "Good", baseTime));
        sub.OnEvent(new AlarmStateChanged(InstanceName, "AlarmX",
            ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmState.Active, 100, baseTime));
        sub.OnEvent(new AttributeValueChanged(InstanceName, "IO", "B", 2, "Good", baseTime));

        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            baseTime);
        ctx.BridgeActor.Tell(snapshot);

        AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 4; } },
            TimeSpan.FromSeconds(3));
        lock (ctx.ReceivedEvents)
        {
            Assert.IsType<DebugViewSnapshot>(ctx.ReceivedEvents[0]);
            Assert.Equal("A", Assert.IsType<AttributeValueChanged>(ctx.ReceivedEvents[1]).AttributeName);
            Assert.Equal("AlarmX", Assert.IsType<AlarmStateChanged>(ctx.ReceivedEvents[2]).AlarmName);
            Assert.Equal("B", Assert.IsType<AttributeValueChanged>(ctx.ReceivedEvents[3]).AttributeName);
        }
    }

    [Fact]
    public void PassThrough_After_Flush_Delivers_Subsequent_Events_Immediately()
    {
        // M2.18: after the snapshot+flush the actor switches to pass-through — later
        // events go straight to _onEvent (no buffering, no dup).
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            DateTimeOffset.UtcNow);
        ctx.BridgeActor.Tell(snapshot);

        AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 1; } },
            TimeSpan.FromSeconds(3));

        // Post-snapshot event — must be delivered immediately, exactly once.
        var postEvent = new AttributeValueChanged(InstanceName, "IO", "Temp", 42.5, "Good",
            DateTimeOffset.UtcNow);
        ctx.MockGrpcClient.SubscribeCalls[0].OnEvent(postEvent);

        AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 2; } },
            TimeSpan.FromSeconds(3));
        lock (ctx.ReceivedEvents)
        {
            Assert.IsType<AttributeValueChanged>(ctx.ReceivedEvents[1]);
        }
    }

    [Fact]
    public void InstanceNotFound_After_StreamFirst_Tears_Down_Stream_And_Does_Not_PassThrough()
    {
        // M2.18 + M2.11: stream-first means the gRPC subscription is already open
        // when an InstanceNotFound snapshot arrives. The bridge must tear that stream
        // down (Unsubscribe the just-opened correlation), deliver the not-found
        // snapshot, NOT enter pass-through, and stop cleanly.
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();

        // Stream opened up-front (stream-first).
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        var notFoundSnapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            DateTimeOffset.UtcNow,
            InstanceNotFound: true);

        Watch(ctx.BridgeActor);
        ctx.BridgeActor.Tell(notFoundSnapshot);

        // Not-found snapshot delivered.
        AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 1; } },
            TimeSpan.FromSeconds(3));
        lock (ctx.ReceivedEvents)
        {
            Assert.True(Assert.IsType<DebugViewSnapshot>(ctx.ReceivedEvents[0]).InstanceNotFound);
        }

        // The just-opened stream must be torn down.
        AwaitCondition(() => ctx.MockGrpcClient.UnsubscribedCorrelationIds.Contains("corr-1"),
            TimeSpan.FromSeconds(3));

        // Stops cleanly.
        ExpectTerminated(ctx.BridgeActor, TimeSpan.FromSeconds(3));

        // No pass-through: an event arriving after the stop is not delivered.
        var late = new AttributeValueChanged(InstanceName, "IO", "Temp", 1, "Good", DateTimeOffset.UtcNow);
        ctx.MockGrpcClient.SubscribeCalls[0].OnEvent(late);
        Thread.Sleep(200);
        lock (ctx.ReceivedEvents) { Assert.Single(ctx.ReceivedEvents); }
    }

    [Fact]
    public void Reconnect_During_Buffering_Phase_Keeps_Buffering_Until_Snapshot()
    {
        // M2.18: a gRPC error/reconnect BEFORE the snapshot arrives must remain in the
        // buffering phase — events on the new stream are still buffered, then flushed
        // when the snapshot finally arrives.
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        // Error before snapshot → reconnect (still buffering).
        ctx.MockGrpcClient.SubscribeCalls[0].OnError(new Exception("pre-snapshot blip"));
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 2, TimeSpan.FromSeconds(5));

        // Event on the reconnected stream — still buffered (snapshot not yet delivered).
        var gapEvent = new AttributeValueChanged(InstanceName, "IO", "Late", 7, "Good",
            DateTimeOffset.UtcNow);
        ctx.MockGrpcClient.SubscribeCalls[1].OnEvent(gapEvent);
        lock (ctx.ReceivedEvents) { Assert.Empty(ctx.ReceivedEvents); }

        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            DateTimeOffset.UtcNow);
        ctx.BridgeActor.Tell(snapshot);

        // snapshot + the event buffered across the reconnect.
        AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 2; } },
            TimeSpan.FromSeconds(3));
        lock (ctx.ReceivedEvents)
        {
            Assert.IsType<DebugViewSnapshot>(ctx.ReceivedEvents[0]);
            Assert.Equal("Late", Assert.IsType<AttributeValueChanged>(ctx.ReceivedEvents[1]).AttributeName);
        }
    }

    [Fact]
    public void Reconnect_After_Snapshot_Resumes_PassThrough_Not_Buffering()
    {
        // M2.18: a mid-session reconnect (after the snapshot was already delivered)
        // must resume pass-through — the snapshot is a one-time thing and events on
        // the reconnected stream are delivered immediately, not re-buffered.
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            DateTimeOffset.UtcNow);
        ctx.BridgeActor.Tell(snapshot);
        AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 1; } },
            TimeSpan.FromSeconds(3));

        // Mid-session reconnect.
        ctx.MockGrpcClient.SubscribeCalls[0].OnError(new Exception("mid-session blip"));
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 2, TimeSpan.FromSeconds(5));

        // Event on the reconnected stream — delivered immediately (pass-through).
        var postEvent = new AttributeValueChanged(InstanceName, "IO", "Temp", 9, "Good",
            DateTimeOffset.UtcNow);
        ctx.MockGrpcClient.SubscribeCalls[1].OnEvent(postEvent);

        AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 2; } },
            TimeSpan.FromSeconds(3));
        lock (ctx.ReceivedEvents)
        {
            Assert.Equal("Temp", Assert.IsType<AttributeValueChanged>(ctx.ReceivedEvents[1]).AttributeName);
        }
    }

    [Fact]
    public void RetryCount_RecoveredOnlyAfterStreamStaysStableForStabilityWindow()
    {
        // Communication-008: after a stream has been connected for the stability
        // window, the retry budget is recovered — a later transient fault then gets
        // a fresh set of retries rather than being counted against the old budget.
        DebugStreamBridgeActor.StabilityWindow = TimeSpan.FromMilliseconds(300);
        try
        {
            var ctx = CreateBridgeActor();
            ctx.CommProbe.ExpectMsg<SiteEnvelope>();

            var snapshot = new DebugViewSnapshot(
                InstanceName,
                new List<AttributeValueChanged>(),
                new List<AlarmStateChanged>(),
                DateTimeOffset.UtcNow);

            Watch(ctx.BridgeActor);
            ctx.BridgeActor.Tell(snapshot);
            AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

            // Two failures — but each new stream stays up long enough (the mock
            // stream only completes on cancel) for the stability window to elapse
            // and reset the retry budget before the next failure.
            for (var i = 0; i < 5; i++)
            {
                Thread.Sleep(450); // exceed the 300ms stability window
                ctx.MockGrpcClient.SubscribeCalls[i].OnError(new Exception($"Error {i + 1}"));
                var expected = i + 2;
                AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == expected, TimeSpan.FromSeconds(5));
            }

            // Five well-spaced failures did NOT terminate the actor because each
            // reconnect recovered its retry budget after the stability window.
            Assert.False(ctx.TerminatedFlag[0]);
        }
        finally
        {
            DebugStreamBridgeActor.StabilityWindow = TimeSpan.FromSeconds(30);
        }
    }

    // ── WP2.3: bounded pre-snapshot buffer, hard snapshot deadline, timeout hygiene ──

    [Fact]
    public void PreSnapshotBuffer_IsCapped_DropsOldest_AndCountsTheDrops()
    {
        // Before the cap a snapshot that never arrived buffered every live event on the
        // CENTRAL node without limit — one wedged session on a chatty instance was enough
        // to grow unbounded. Now the oldest are evicted and counted.
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>(); // subscribe envelope; no snapshot is ever sent

        const int cap = 20_000;
        const int overflow = 250;
        var before = Interlocked.Read(ref DebugStreamBridgeActor.TotalPreSnapshotDropped);

        var t = DateTimeOffset.UtcNow;
        for (var i = 0; i < cap + overflow; i++)
        {
            ctx.BridgeActor.Tell(new AttributeValueChanged(
                InstanceName, "Modules.IO", $"Attr{i}", i, "Good", t.AddMilliseconds(i)));
        }

        // The overflow was evicted (drop-oldest) and counted.
        AwaitCondition(
            () => Interlocked.Read(ref DebugStreamBridgeActor.TotalPreSnapshotDropped) - before >= overflow,
            TimeSpan.FromSeconds(10));

        // The session is still healthy: the snapshot can still arrive and flush the
        // (capped) buffer — the newest events, which the snapshot may predate, survived.
        var snapshot = new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            t.AddMilliseconds(-1));
        ctx.BridgeActor.Tell(snapshot);

        // One awaited block, gated on the EXACT final count. This used to gate on
        // "Count >= cap" and then assert "Count == cap + 1" bare — a gate strictly
        // weaker than the assertion it guards, which therefore orders nothing with
        // respect to the last event. FlushBuffer delivers the buffered events one
        // by one via _onEvent inside a single loop, so the poll can legitimately
        // observe the count crossing `cap` while the loop still has an event to
        // go. Reproduced deterministically by parking that loop for 3 s after its
        // 19,999th delivery: the bare form failed with "Expected: 20001 / Actual:
        // 20000". The claims are unchanged — exactly the snapshot plus the capped
        // retained events, with the newest survivor last.
        AwaitAssert(() =>
        {
            lock (ctx.ReceivedEvents)
            {
                // Snapshot + exactly the retained (capped) events, and the newest survived.
                Assert.Equal(cap + 1, ctx.ReceivedEvents.Count);
                var lastAttr = ctx.ReceivedEvents.OfType<AttributeValueChanged>().Last();
                Assert.Equal($"Attr{cap + overflow - 1}", lastAttr.AttributeName);
            }
        }, TimeSpan.FromSeconds(10));
    }

    [Fact]
    public void NoSnapshotWithinDeadline_FailsTheSession_InsteadOfBufferingForever()
    {
        // Nothing else ends a session wedged in the buffering phase: a lost site reply
        // raises no gRPC error, and stream events no longer reset the orphan timeout.
        DebugStreamBridgeActor.SnapshotTimeout = TimeSpan.FromMilliseconds(300);
        try
        {
            var ctx = CreateBridgeActor();
            ctx.CommProbe.ExpectMsg<SiteEnvelope>(); // subscribe request — never answered

            Watch(ctx.BridgeActor);
            ExpectTerminated(ctx.BridgeActor, TimeSpan.FromSeconds(5));

            // The consumer is told, so the UI can surface the failure and reopen.
            Assert.True(ctx.TerminatedFlag[0]);
            // And the site-side relay was released rather than left as a zombie.
            AwaitCondition(
                () => ctx.MockGrpcClient.UnsubscribedCorrelationIds.Contains("corr-1"),
                TimeSpan.FromSeconds(3));
        }
        finally
        {
            DebugStreamBridgeActor.SnapshotTimeout = TimeSpan.FromSeconds(60);
        }
    }

    [Fact]
    public void SnapshotArrival_StandsDownTheDeadline()
    {
        // The deadline must not fire after a healthy snapshot — a live session would
        // otherwise be killed mid-stream.
        DebugStreamBridgeActor.SnapshotTimeout = TimeSpan.FromMilliseconds(300);
        try
        {
            var ctx = CreateBridgeActor();
            ctx.CommProbe.ExpectMsg<SiteEnvelope>();

            ctx.BridgeActor.Tell(new DebugViewSnapshot(
                InstanceName,
                new List<AttributeValueChanged>(),
                new List<AlarmStateChanged>(),
                DateTimeOffset.UtcNow));

            Thread.Sleep(700); // well past the deadline
            Assert.False(ctx.TerminatedFlag[0]);

            // Still serving: a post-snapshot event passes straight through.
            ctx.MockGrpcClient.SubscribeCalls[0].OnEvent(new AlarmStateChanged(
                InstanceName, "PumpFault", Commons.Types.Enums.AlarmState.Active, 500,
                DateTimeOffset.UtcNow));
            AwaitCondition(() =>
            {
                lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.OfType<AlarmStateChanged>().Any(); }
            }, TimeSpan.FromSeconds(3));
        }
        finally
        {
            DebugStreamBridgeActor.SnapshotTimeout = TimeSpan.FromSeconds(60);
        }
    }

    [Fact]
    public void StreamEvents_StillReachTheConsumer_ThroughTheWrappedCallbackPath()
    {
        // The gRPC callback wraps every event in LiveDebugStreamEvent; the wrapper must be
        // transparent to delivery (it exists only to keep the high-volume path explicit —
        // the orphan net keys off the consumer keepalive, not the mailbox).
        var ctx = CreateBridgeActor();
        ctx.CommProbe.ExpectMsg<SiteEnvelope>();
        AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));

        ctx.BridgeActor.Tell(new DebugViewSnapshot(
            InstanceName,
            new List<AttributeValueChanged>(),
            new List<AlarmStateChanged>(),
            DateTimeOffset.UtcNow));

        var evt = new AttributeValueChanged(
            InstanceName, "Modules.IO", "Temperature", 42.5, "Good", DateTimeOffset.UtcNow);
        ctx.MockGrpcClient.SubscribeCalls[0].OnEvent(evt);

        AwaitCondition(() =>
        {
            lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.OfType<AttributeValueChanged>().Any(); }
        }, TimeSpan.FromSeconds(3));
    }

    // ----- Orphan net: measures the CONSUMER, not the mailbox ----- //

    [Fact]
    public void HealthySession_StreamingEvents_WithConsumerKeepalives_SurvivesWellPastTheOrphanWindow()
    {
        // THE regression this closes: with the orphan net armed off the mailbox
        // (SetReceiveTimeout) and stream events correctly excluded from it, nothing recurring
        // reset it — the snapshot lands once, GrpcStreamStable once — so a perfectly healthy
        // session self-terminated one window later and the consumer was told
        // "Site disconnected". Here the session streams events and receives the keepalive
        // DebugStreamService sends while it is attached; it must live through MANY windows.
        DebugStreamBridgeActor.ConsumerIdleTimeout = TimeSpan.FromMilliseconds(400);
        DebugStreamBridgeActor.ConsumerLivenessCheckInterval = TimeSpan.FromMilliseconds(50);
        try
        {
            var ctx = CreateBridgeActor();
            ctx.CommProbe.ExpectMsg<SiteEnvelope>();
            AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));
            Watch(ctx.BridgeActor);

            ctx.BridgeActor.Tell(new DebugViewSnapshot(
                InstanceName,
                new List<AttributeValueChanged>(),
                new List<AlarmStateChanged>(),
                DateTimeOffset.UtcNow));

            // ~5 orphan windows of pure stream traffic + consumer keepalives, and no other
            // mailbox activity whatsoever (no reconnects, no snapshots, no stop).
            var deadline = DateTime.UtcNow.AddSeconds(2);
            var delivered = 0;
            while (DateTime.UtcNow < deadline)
            {
                ctx.MockGrpcClient.SubscribeCalls[0].OnEvent(new AttributeValueChanged(
                    InstanceName, "Modules.IO", "Temperature", 20.0 + delivered, "Good",
                    DateTimeOffset.UtcNow));
                delivered++;
                // What DebugStreamService's shared timer does for an attached session.
                ctx.BridgeActor.Tell(new DebugStreamConsumerAlive());
                Thread.Sleep(100);
            }

            ExpectNoMsg(TimeSpan.FromMilliseconds(100));
            Assert.False(ctx.TerminatedFlag[0]);

            // Still serving: the events all arrived and the actor is alive.
            AwaitCondition(() =>
            {
                lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.OfType<AttributeValueChanged>().Count() == delivered; }
            }, TimeSpan.FromSeconds(3));
            Assert.False(ctx.BridgeActor.IsNobody());
        }
        finally
        {
            DebugStreamBridgeActor.ConsumerIdleTimeout = TimeSpan.FromMinutes(5);
            DebugStreamBridgeActor.ConsumerLivenessCheckInterval = TimeSpan.FromSeconds(30);
        }
    }

    [Fact]
    public void OrphanedSession_ConsumerGone_StillTerminates_EvenWhileEventsKeepArriving()
    {
        // The other half of the contract: site chatter must NOT hold an abandoned session
        // open. No keepalive arrives (the consumer is gone), so the session terminates,
        // unsubscribes from the site and reports termination — while events stream in.
        DebugStreamBridgeActor.ConsumerIdleTimeout = TimeSpan.FromMilliseconds(400);
        DebugStreamBridgeActor.ConsumerLivenessCheckInterval = TimeSpan.FromMilliseconds(50);
        try
        {
            var ctx = CreateBridgeActor();
            ctx.CommProbe.ExpectMsg<SiteEnvelope>();
            AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));
            Watch(ctx.BridgeActor);

            ctx.BridgeActor.Tell(new DebugViewSnapshot(
                InstanceName,
                new List<AttributeValueChanged>(),
                new List<AlarmStateChanged>(),
                DateTimeOffset.UtcNow));

            // Keep the site chatty for longer than the orphan window — with no keepalive.
            var deadline = DateTime.UtcNow.AddSeconds(1);
            while (DateTime.UtcNow < deadline)
            {
                ctx.MockGrpcClient.SubscribeCalls[0].OnEvent(new AttributeValueChanged(
                    InstanceName, "Modules.IO", "Temperature", 42.5, "Good", DateTimeOffset.UtcNow));
                Thread.Sleep(50);
            }

            ExpectTerminated(ctx.BridgeActor, TimeSpan.FromSeconds(3));
            Assert.True(ctx.TerminatedFlag[0]);
            Assert.Contains("corr-1", ctx.MockGrpcClient.UnsubscribedCorrelationIds);
        }
        finally
        {
            DebugStreamBridgeActor.ConsumerIdleTimeout = TimeSpan.FromMinutes(5);
            DebugStreamBridgeActor.ConsumerLivenessCheckInterval = TimeSpan.FromSeconds(30);
        }
    }

    [Fact]
    public void ConsumerKeepalive_RenewsTheWindow_AfterANearMiss()
    {
        // A single late keepalive is enough to save a session — the stamp is refreshed, not
        // a one-shot grace period.
        DebugStreamBridgeActor.ConsumerIdleTimeout = TimeSpan.FromMilliseconds(500);
        DebugStreamBridgeActor.ConsumerLivenessCheckInterval = TimeSpan.FromMilliseconds(50);
        try
        {
            var ctx = CreateBridgeActor();
            ctx.CommProbe.ExpectMsg<SiteEnvelope>();
            Watch(ctx.BridgeActor);

            Thread.Sleep(350);                                   // most of the window burnt
            ctx.BridgeActor.Tell(new DebugStreamConsumerAlive()); // …then the consumer checks in
            Thread.Sleep(350);                                   // past the ORIGINAL deadline

            Assert.False(ctx.TerminatedFlag[0]);

            // …and once the keepalives stop, it does terminate.
            ExpectTerminated(ctx.BridgeActor, TimeSpan.FromSeconds(3));
        }
        finally
        {
            DebugStreamBridgeActor.ConsumerIdleTimeout = TimeSpan.FromMinutes(5);
            DebugStreamBridgeActor.ConsumerLivenessCheckInterval = TimeSpan.FromSeconds(30);
        }
    }
}

/// <summary>
/// Mock gRPC client that records SubscribeAsync and Unsubscribe calls.
/// <para>
/// <b>Thread safety:</b> <see cref="SubscribeCalls"/> and
/// <see cref="UnsubscribedCorrelationIds"/> are written from the actor/background thread
/// (via <see cref="SubscribeAsync"/> and <see cref="Unsubscribe"/>) and read from the test
/// thread (via <c>AwaitCondition</c> / assertions). All access goes through a shared lock
/// to match the <c>lock (events)</c> pattern used for <c>ctx.ReceivedEvents</c>.
/// </para>
/// </summary>
internal class MockSiteStreamGrpcClient : SiteStreamGrpcClient
{
    private readonly object _lock = new();
    private readonly List<MockSubscription> _subscribeCalls = new();
    private readonly List<string> _unsubscribedCorrelationIds = new();

    /// <summary>Returns a snapshot of subscribe calls, taken under the internal lock.</summary>
    public List<MockSubscription> SubscribeCalls { get { lock (_lock) { return _subscribeCalls.ToList(); } } }

    /// <summary>Returns a snapshot of unsubscribed correlation IDs, taken under the internal lock.</summary>
    public List<string> UnsubscribedCorrelationIds { get { lock (_lock) { return _unsubscribedCorrelationIds.ToList(); } } }

    private MockSiteStreamGrpcClient(bool _) : base() { }

    public MockSiteStreamGrpcClient() : base()
    {
    }

    public override Task SubscribeAsync(
        string correlationId,
        string instanceUniqueName,
        Action<object> onEvent,
        Action<Exception> onError,
        Action onCompleted,
        CancellationToken ct)
    {
        var subscription = new MockSubscription(
            correlationId, instanceUniqueName, onEvent, onError, onCompleted, ct);
        lock (_lock) { _subscribeCalls.Add(subscription); }

        // Return a task that completes when cancelled (simulates long-running stream)
        var tcs = new TaskCompletionSource();
        ct.Register(() => tcs.TrySetResult());
        return tcs.Task;
    }

    public override void Unsubscribe(string correlationId)
    {
        lock (_lock) { _unsubscribedCorrelationIds.Add(correlationId); }
    }
}

internal record MockSubscription(
    string CorrelationId,
    string InstanceUniqueName,
    Action<object> OnEvent,
    Action<Exception> OnError,
    Action OnCompleted,
    CancellationToken CancellationToken);

/// <summary>
/// Factory that always returns the pre-configured mock client.
/// </summary>
internal class MockSiteStreamGrpcClientFactory : SiteStreamGrpcClientFactory
{
    private readonly MockSiteStreamGrpcClient _mockClient;
    public List<string> RequestedEndpoints { get; } = new();

    public MockSiteStreamGrpcClientFactory(MockSiteStreamGrpcClient mockClient)
        : base(Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance)
    {
        _mockClient = mockClient;
    }

    public override SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint)
    {
        RequestedEndpoints.Add(grpcEndpoint);
        return _mockClient;
    }

    // Mirrors the real factory's TryGet: returns the client only for an endpoint
    // already opened via GetOrCreate, never creating one. Lets teardown/failover
    // unsubscribe paths (now TryGet-based, Task 6) resolve the mock client.
    public override SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint)
        => RequestedEndpoints.Contains(grpcEndpoint) ? _mockClient : null;
}

/// <summary>
/// Endpoint-aware mock factory: hands out a distinct <see cref="MockSiteStreamGrpcClient"/>
/// per endpoint, mirroring the real factory's corrected NodeA→NodeB failover behaviour
/// so node-flip coverage is meaningful (Communication-015).
/// </summary>
internal class EndpointTrackingGrpcClientFactory : SiteStreamGrpcClientFactory
{
    private readonly System.Collections.Concurrent.ConcurrentDictionary<string, MockSiteStreamGrpcClient> _byEndpoint = new();

    public EndpointTrackingGrpcClientFactory()
        : base(Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance)
    {
    }

    public MockSiteStreamGrpcClient ClientFor(string endpoint) =>
        _byEndpoint.GetOrAdd(endpoint, _ => new MockSiteStreamGrpcClient());

    /// <summary>Number of distinct endpoint clients created so far (channels opened).</summary>
    public int CreatedCount => _byEndpoint.Count;

    public override SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint)
        => ClientFor(grpcEndpoint);

    // TryGet never creates: returns the client only for an already-opened endpoint.
    public override SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint)
        => _byEndpoint.TryGetValue(grpcEndpoint, out var client) ? client : null;
}
