using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;

namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;

/// <summary>
/// DataConnectionLayer-S1 (MxGateway half): the adapter instance is reused across
/// reconnects. Before the fix, <c>ConnectAsync</c> overwrote <c>_client</c> and
/// <c>_eventLoopCts</c> without cancelling the previous event loop or disposing the
/// previous client — every gateway flap accumulated an orphaned stream/socket, and
/// the dying old loop's fault path could raise <c>Disconnected</c> against the new
/// session after the guard was reset.
/// </summary>
[Collection("DataConnectionManagerActor")]
public class MxGatewayDataConnectionReconnectTests
{
    [Fact]
    public async Task Reconnect_DisposesPreviousClient_AndCancelsPreviousEventLoop()
    {
        var clients = new List<IMxGatewayClient>();
        var loopTokens = new List<CancellationToken>();
        var factory = Substitute.For<IMxGatewayClientFactory>();
        factory.Create().Returns(_ =>
        {
            var c = Substitute.For<IMxGatewayClient>();
            c.RunEventLoopAsync(Arg.Any<Action<MxValueUpdate>>(), Arg.Do<CancellationToken>(t => loopTokens.Add(t)))
                .Returns(Task.Delay(Timeout.Infinite));
            clients.Add(c);
            return c;
        });
        var adapter = new MxGatewayDataConnection(factory, NullLogger<MxGatewayDataConnection>.Instance);
        var details = new Dictionary<string, string> { ["Endpoint"] = "http://gw:5000", ["ApiKey"] = "k" };

        // The event loop is pumped fire-and-forget (ConnectAsync → Task.Run →
        // RunEventLoopAsync), so the token is recorded on a thread-pool continuation.
        // Barrier on each recorded token before proceeding.
        await adapter.ConnectAsync(details);
        await WaitUntilAsync(() => loopTokens.Count == 1);

        await adapter.ConnectAsync(details); // reconnect
        await WaitUntilAsync(() => loopTokens.Count == 2);

        Assert.Equal(2, clients.Count);
        await clients[0].Received(1).DisposeAsync();
        Assert.True(loopTokens[0].IsCancellationRequested);   // old loop cancelled
        Assert.False(loopTokens[1].IsCancellationRequested);  // new loop live
    }

    [Fact]
    public async Task StaleEventLoopRpcFault_AfterReconnect_DoesNotSignalDisconnected()
    {
        var loops = new List<TaskCompletionSource>();
        var loopTokens = new List<CancellationToken>();
        var adapter = CreateAdapterWithControllableLoops(loops, loopTokens, out var disconnectsRef);

        var details = new Dictionary<string, string> { ["Endpoint"] = "http://gw:5000", ["ApiKey"] = "k" };

        await adapter.ConnectAsync(details);
        await WaitUntilAsync(() => loopTokens.Count == 1);
        await adapter.ConnectAsync(details);          // reconnect: cancels loop #0, resets _disconnectFired
        await WaitUntilAsync(() => loopTokens.Count == 2);

        // The OLD loop observes its cancellation as a gRPC fault, not an OCE —
        // the Grpc.Net default without ThrowOperationCanceledOnCancellation.
        loops[0].SetException(new RpcException(new Status(StatusCode.Cancelled, "call cancelled")));
        await Task.Delay(200);
        Assert.Equal(0, Volatile.Read(ref disconnectsRef.Value));  // stale fault absorbed — the fresh connection must not flap

        // The CURRENT loop's genuine fault must still signal, exactly once.
        loops[1].SetException(new RpcException(new Status(StatusCode.Unavailable, "gateway gone")));
        await WaitUntilAsync(() => Volatile.Read(ref disconnectsRef.Value) == 1);
    }

    [Fact]
    public async Task StaleEventLoopObjectDisposedFault_AfterReconnect_DoesNotSignalDisconnected()
    {
        var loops = new List<TaskCompletionSource>();
        var loopTokens = new List<CancellationToken>();
        var adapter = CreateAdapterWithControllableLoops(loops, loopTokens, out var disconnectsRef);

        var details = new Dictionary<string, string> { ["Endpoint"] = "http://gw:5000", ["ApiKey"] = "k" };

        await adapter.ConnectAsync(details);
        await WaitUntilAsync(() => loopTokens.Count == 1);
        await adapter.ConnectAsync(details);          // reconnect
        await WaitUntilAsync(() => loopTokens.Count == 2);

        // The old loop faults with the concurrent-DisposeAsync shape.
        loops[0].SetException(new ObjectDisposedException("MxGatewayClient"));
        await Task.Delay(200);
        Assert.Equal(0, Volatile.Read(ref disconnectsRef.Value));
    }

    private sealed class IntRef { public int Value; }

    private static MxGatewayDataConnection CreateAdapterWithControllableLoops(
        List<TaskCompletionSource> loops, List<CancellationToken> loopTokens, out IntRef disconnectsRef)
    {
        var factory = Substitute.For<IMxGatewayClientFactory>();
        factory.Create().Returns(_ =>
        {
            var c = Substitute.For<IMxGatewayClient>();
            var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
            loops.Add(tcs);
            c.RunEventLoopAsync(Arg.Any<Action<MxValueUpdate>>(), Arg.Do<CancellationToken>(t => loopTokens.Add(t)))
                .Returns(tcs.Task);
            return c;
        });
        var adapter = new MxGatewayDataConnection(factory, NullLogger<MxGatewayDataConnection>.Instance);
        var refHolder = new IntRef();
        adapter.Disconnected += () => Interlocked.Increment(ref refHolder.Value);
        disconnectsRef = refHolder;
        return adapter;
    }

    private static async Task WaitUntilAsync(Func<bool> condition)
    {
        for (var i = 0; i < 100 && !condition(); i++)
            await Task.Delay(50);
        Assert.True(condition(), "condition not met within timeout");
    }
}
