using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;

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

/// <summary>
/// DataConnectionLayer-003: structural regression guard. RealOpcUaClient's
/// monitored-item / callback maps are read from the OPC UA SDK's publish threads
/// concurrently with subscribe/disconnect mutations on other threads. They must be
/// concurrent collections, not plain Dictionary. This is verified structurally
/// because RealOpcUaClient wraps concrete OPC Foundation SDK types and cannot be
/// exercised without a live OPC UA server.
/// </summary>
public class RealOpcUaClientThreadSafetyTests
{
    [Theory]
    [InlineData("_callbacks")]
    [InlineData("_monitoredItems")]
    public void DCL003_SharedDictionaryFields_AreConcurrentCollections(string fieldName)
    {
        var field = typeof(RealOpcUaClient)
            .GetField(fieldName,
                System.Reflection.BindingFlags.Instance |
                System.Reflection.BindingFlags.NonPublic);

        Assert.NotNull(field);

        var fieldType = field!.FieldType;
        Assert.True(
            fieldType.IsGenericType &&
            fieldType.GetGenericTypeDefinition() == typeof(System.Collections.Concurrent.ConcurrentDictionary<,>),
            $"RealOpcUaClient.{fieldName} must be a ConcurrentDictionary<,> for thread safety, " +
            $"but was {fieldType.Name}.");
    }
}

/// <summary>
/// DataConnectionLayer-019: <see cref="OpcUaDataConnection"/> previously kept a
/// dead <c>Dictionary&lt;string,string&gt; _subscriptionHandles</c> field that was
/// written and removed across thread-pool continuations but never read. Plain
/// Dictionary writes from concurrent post-await continuations are racy; the
/// field was a latent bug waiting for any future reader. The fix deletes the
/// field rather than converting it to ConcurrentDictionary (bookkeeping already
/// lives in <c>RealOpcUaClient._monitoredItems/_callbacks</c> and
/// <c>DataConnectionActor._subscriptionIds</c>). This test guards against
/// regression — anyone re-introducing a non-concurrent shared dictionary on
/// the adapter must justify it explicitly.
/// </summary>
public class OpcUaDataConnectionThreadSafetyTests
{
    [Fact]
    public void DCL019_OpcUaDataConnection_HasNoNonConcurrentSharedDictionary()
    {
        // Reflection-walk every instance field on the adapter. Any
        // System.Collections.Generic.Dictionary<,> field would be a regression:
        // either dead state (return it) or live state mutated from continuations
        // (convert to ConcurrentDictionary). Either way, fail the test.
        var dictionaryFields = typeof(OpcUaDataConnection)
            .GetFields(System.Reflection.BindingFlags.Instance |
                       System.Reflection.BindingFlags.NonPublic |
                       System.Reflection.BindingFlags.Public)
            .Where(f => f.FieldType.IsGenericType &&
                        f.FieldType.GetGenericTypeDefinition() == typeof(Dictionary<,>))
            .Select(f => f.Name)
            .ToList();

        Assert.True(dictionaryFields.Count == 0,
            $"OpcUaDataConnection must not hold a non-concurrent Dictionary<,> field; " +
            $"found: {string.Join(", ", dictionaryFields)}. See DCL-019.");
    }
}

/// <summary>
/// DataConnectionLayer-012: secure-by-default certificate handling.
/// </summary>
public class OpcUaCertificateDefaultTests
{
    [Fact]
    public void DCL012_OpcUaConnectionOptions_AutoAcceptUntrustedCerts_DefaultsToFalse()
    {
        // Regression test for DataConnectionLayer-012. AutoAcceptUntrustedCerts defaulted
        // to true, accepting every server certificate unconditionally and defeating the
        // Sign / SignAndEncrypt security modes against an active man-in-the-middle. A
        // secure-by-default posture rejects untrusted certs unless explicitly opted in.
        var options = new OpcUaConnectionOptions();
        Assert.False(options.AutoAcceptUntrustedCerts);
    }
}

/// <summary>
/// DataConnectionLayer-014: the DCL-012 auto-accept-certificate security warning is
/// only effective if RealOpcUaClient is built with a real logger. The only production
/// path that constructs one is RealOpcUaClientFactory.Create(); that factory must
/// thread a logger through, otherwise the warning sinks into NullLogger and an
/// operator who enables AutoAcceptUntrustedCerts sees no signal anywhere.
/// </summary>
public class RealOpcUaClientFactoryLoggerTests
{
    private static ILogger<RealOpcUaClient> ReadLogger(RealOpcUaClient client)
    {
        var field = typeof(RealOpcUaClient).GetField("_logger",
            System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
        Assert.NotNull(field);
        return (ILogger<RealOpcUaClient>)field!.GetValue(client)!;
    }

    [Fact]
    public void DCL014_RealOpcUaClientFactory_CreatesClientWithRealLogger()
    {
        // Regression test for DataConnectionLayer-014. RealOpcUaClientFactory.Create()
        // constructed `new RealOpcUaClient(_globalOptions)` with no logger, so the
        // DCL-012 man-in-the-middle warning was always discarded by NullLogger in
        // production. After the fix the factory accepts an ILoggerFactory and passes a
        // real ILogger<RealOpcUaClient> into every client it creates.
        using var loggerFactory = LoggerFactory.Create(b => { });
        var factory = new RealOpcUaClientFactory(new OpcUaGlobalOptions(), loggerFactory);

        var client = factory.Create();

        var logger = ReadLogger((RealOpcUaClient)client);
        Assert.NotSame(NullLogger<RealOpcUaClient>.Instance, logger);
    }

    [Fact]
    public void DCL014_DataConnectionFactory_ThreadsLoggerToRealOpcUaClient()
    {
        // The full production wiring: DataConnectionFactory holds an ILoggerFactory and
        // registers the OpcUa adapter. The RealOpcUaClient it ultimately builds must end
        // up with a real (non-Null) logger so the auto-accept-cert warning is visible.
        using var loggerFactory = LoggerFactory.Create(b => { });
        var dataConnectionFactory = new DataConnectionFactory(loggerFactory);
        var adapter = (OpcUaDataConnection)dataConnectionFactory.Create(
            "OpcUa", new Dictionary<string, string>());

        // Reach the RealOpcUaClient the adapter would create on connect.
        var clientFactoryField = typeof(OpcUaDataConnection).GetField("_clientFactory",
            System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
        Assert.NotNull(clientFactoryField);
        var clientFactory = (RealOpcUaClientFactory)clientFactoryField!.GetValue(adapter)!;
        var client = (RealOpcUaClient)clientFactory.Create();

        var logger = ReadLogger(client);
        Assert.NotSame(NullLogger<RealOpcUaClient>.Instance, logger);
    }
}

/// <summary>
/// DataConnectionLayer-009: failover-stability tunables must be configurable.
/// </summary>
public class DataConnectionOptionsStabilityTests
{
    [Fact]
    public void DCL009_StableConnectionThreshold_IsConfigurable_WithSixtySecondDefault()
    {
        // Regression test for DataConnectionLayer-009. The unstable-disconnect failover
        // path used a hard-coded 60s StableConnectionThreshold constant inside
        // DataConnectionActor. It must live on DataConnectionOptions like the other
        // tunables (ReconnectInterval, TagResolutionRetryInterval, WriteTimeout) so it
        // is configurable via appsettings.json.
        var options = new DataConnectionOptions();
        Assert.Equal(TimeSpan.FromSeconds(60), options.StableConnectionThreshold);

        options.StableConnectionThreshold = TimeSpan.FromSeconds(30);
        Assert.Equal(TimeSpan.FromSeconds(30), options.StableConnectionThreshold);
    }
}

/// <summary>
/// WP-7: Tests for OPC UA adapter.
/// </summary>
public class OpcUaDataConnectionTests
{
    private readonly IOpcUaClient _mockClient;
    private readonly IOpcUaClientFactory _mockFactory;
    private readonly OpcUaDataConnection _adapter;

    public OpcUaDataConnectionTests()
    {
        _mockClient = Substitute.For<IOpcUaClient>();
        _mockFactory = Substitute.For<IOpcUaClientFactory>();
        _mockFactory.Create().Returns(_mockClient);
        _adapter = new OpcUaDataConnection(_mockFactory, NullLogger<OpcUaDataConnection>.Instance);
    }

    [Fact]
    public async Task Connect_SetsStatusToConnected()
    {
        _mockClient.IsConnected.Returns(true);

        await _adapter.ConnectAsync(new Dictionary<string, string>
        {
            ["EndpointUrl"] = "opc.tcp://localhost:4840"
        });

        Assert.Equal(ConnectionHealth.Connected, _adapter.Status);
        await _mockClient.Received(1).ConnectAsync("opc.tcp://localhost:4840", Arg.Any<OpcUaConnectionOptions?>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Disconnect_SetsStatusToDisconnected()
    {
        _mockClient.IsConnected.Returns(true);
        await _adapter.ConnectAsync(new Dictionary<string, string>());
        await _adapter.DisconnectAsync();

        Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status);
    }

    [Fact]
    public async Task DCL013_ConcurrentConnectionLost_RaisesDisconnectedExactlyOnce()
    {
        // Regression test for DataConnectionLayer-013. RaiseDisconnected used a
        // non-atomic check-then-set on a volatile bool: two threads racing through it
        // (e.g. the keep-alive thread and a ReadAsync failure path, both routed via
        // OnClientConnectionLost) could both observe _disconnectFired == false and both
        // invoke Disconnected. The guard is now an atomic Interlocked.Exchange, so a
        // burst of concurrent connection-lost callbacks fires the event exactly once.
        // Repeat the burst: reconnecting between rounds re-arms the guard, so each
        // round must independently fire Disconnected exactly once. Repetition makes
        // the (timing-dependent) non-atomic race overwhelmingly likely to be caught.
        const int rounds = 25;
        const int threads = 32;
        for (var round = 0; round < rounds; round++)
        {
            _mockClient.IsConnected.Returns(true);
            await _adapter.ConnectAsync(new Dictionary<string, string>());

            var fired = 0;
            void Handler() => Interlocked.Increment(ref fired);
            _adapter.Disconnected += Handler;

            // Fan out: many threads raise the client's ConnectionLost event together.
            using (var ready = new Barrier(threads))
            {
                var tasks = Enumerable.Range(0, threads).Select(_ => Task.Run(() =>
                {
                    ready.SignalAndWait();
                    _mockClient.ConnectionLost += Raise.Event<Action>();
                })).ToArray();
                await Task.WhenAll(tasks);
            }

            _adapter.Disconnected -= Handler;
            Assert.Equal(1, fired);
        }
    }

    [Fact]
    public async Task Subscribe_DelegatesAndReturnsId()
    {
        _mockClient.IsConnected.Returns(true);
        _mockClient.CreateSubscriptionAsync(Arg.Any<string>(), Arg.Any<Action<string, object?, DateTime, uint>>(), Arg.Any<CancellationToken>())
            .Returns("sub-001");

        await _adapter.ConnectAsync(new Dictionary<string, string>());
        var subId = await _adapter.SubscribeAsync("ns=2;s=Tag1", (_, _) => { });

        Assert.Equal("sub-001", subId);
    }

    [Fact]
    public async Task Write_Success_ReturnsGoodResult()
    {
        _mockClient.IsConnected.Returns(true);
        _mockClient.WriteValueAsync("ns=2;s=Tag1", 42, Arg.Any<CancellationToken>())
            .Returns((uint)0);

        await _adapter.ConnectAsync(new Dictionary<string, string>());
        var result = await _adapter.WriteAsync("ns=2;s=Tag1", 42);

        Assert.True(result.Success);
        Assert.Null(result.ErrorMessage);
    }

    [Fact]
    public async Task Write_Failure_ReturnsError()
    {
        _mockClient.IsConnected.Returns(true);
        _mockClient.WriteValueAsync("ns=2;s=Tag1", 42, Arg.Any<CancellationToken>())
            .Returns(0x80000000u);

        await _adapter.ConnectAsync(new Dictionary<string, string>());
        var result = await _adapter.WriteAsync("ns=2;s=Tag1", 42);

        Assert.False(result.Success);
        Assert.Contains("0x80000000", result.ErrorMessage);
    }

    [Fact]
    public async Task Read_BadStatus_ReturnsBadResult()
    {
        _mockClient.IsConnected.Returns(true);
        _mockClient.ReadValueAsync("ns=2;s=Tag1", Arg.Any<CancellationToken>())
            .Returns((null, DateTime.UtcNow, 0x80000000u));

        await _adapter.ConnectAsync(new Dictionary<string, string>());
        var result = await _adapter.ReadAsync("ns=2;s=Tag1");

        Assert.False(result.Success);
    }

    [Fact]
    public async Task Read_GoodStatus_ReturnsValue()
    {
        _mockClient.IsConnected.Returns(true);
        _mockClient.ReadValueAsync("ns=2;s=Tag1", Arg.Any<CancellationToken>())
            .Returns((42.5, DateTime.UtcNow, 0u));

        await _adapter.ConnectAsync(new Dictionary<string, string>());
        var result = await _adapter.ReadAsync("ns=2;s=Tag1");

        Assert.True(result.Success);
        Assert.NotNull(result.Value);
        Assert.Equal(42.5, result.Value!.Value);
        Assert.Equal(QualityCode.Good, result.Value.Quality);
    }

    [Fact]
    public async Task ReadBatch_ReadsAllTags()
    {
        // WP2.1b: ReadBatchAsync is TRUE bulk — ONE client ReadValuesAsync call carrying
        // every requested node, not a per-tag loop.
        _mockClient.IsConnected.Returns(true);
        _mockClient.ReadValuesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
            .Returns(ci => Task.FromResult<IReadOnlyList<OpcUaReadOutcome>>(
                ci.Arg<IReadOnlyList<string>>()
                    .Select(n => new OpcUaReadOutcome(n, 1.0, DateTime.UtcNow, 0u, null))
                    .ToList()));

        await _adapter.ConnectAsync(new Dictionary<string, string>());
        var results = await _adapter.ReadBatchAsync(["tag1", "tag2", "tag3"]);

        Assert.Equal(3, results.Count);
        Assert.All(results.Values, r => Assert.True(r.Success));
        Assert.Single(_mockClient.ReceivedCalls(), c => c.GetMethodInfo().Name == "ReadValuesAsync");
    }

    [Fact]
    public async Task DCL007_ReadBatch_ReturnsPerTagResults_WhenOneTagFails()
    {
        // Regression test for DataConnectionLayer-007. ReadBatchAsync originally looped
        // calling ReadAsync per tag; ReadAsync re-throws any non-cancellation exception, so
        // a single failing tag aborted the whole batch and the caller got NO results for
        // the tags that did read successfully — even though ReadResult already carries a
        // per-tag Success/ErrorMessage shape. The batch is now one bulk service call, and
        // the same invariant holds: a per-node failure row never aborts the batch, and
        // every requested tag comes back in the map.
        _mockClient.IsConnected.Returns(true);
        _mockClient.ReadValuesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
            .Returns(ci => Task.FromResult<IReadOnlyList<OpcUaReadOutcome>>(
                ci.Arg<IReadOnlyList<string>>()
                    .Select(n => n == "bad"
                        ? new OpcUaReadOutcome(n, null, DateTime.UtcNow, 0x80340000u, "node not found")
                        : new OpcUaReadOutcome(n, 1.0, DateTime.UtcNow, 0u, null))
                    .ToList()));

        await _adapter.ConnectAsync(new Dictionary<string, string>());

        var results = await _adapter.ReadBatchAsync(["good1", "bad", "good2"]);

        // Every requested tag is present in the result map.
        Assert.Equal(3, results.Count);
        Assert.True(results["good1"].Success);
        Assert.True(results["good2"].Success);
        // The failing tag is reported as a failed ReadResult, not by aborting the batch.
        Assert.False(results["bad"].Success);
        Assert.NotNull(results["bad"].ErrorMessage);
    }

    [Fact]
    public async Task DCL017_WriteBatch_ReturnsPerTagResults_WhenSomeTagsFail()
    {
        // Regression test for DataConnectionLayer-017. WriteBatchAsync originally looped
        // calling WriteAsync per tag; a mid-batch fault made the whole call throw and the
        // caller lost the per-tag outcomes for the tags that already wrote. The batch is
        // now ONE bulk service call (WP2.1b), and the invariant is unchanged: per-node
        // failures are reported as failed WriteResult rows and every requested tag is
        // present in the returned map.
        _mockClient.IsConnected.Returns(true);
        await _adapter.ConnectAsync(new Dictionary<string, string>());

        _mockClient.WriteValuesAsync(
                Arg.Any<IReadOnlyList<(string NodeId, object? Value)>>(), Arg.Any<CancellationToken>())
            .Returns(ci => Task.FromResult<IReadOnlyList<OpcUaWriteOutcome>>(
                ci.Arg<IReadOnlyList<(string NodeId, object? Value)>>()
                    .Select(v => v.NodeId == "tag1"
                        ? new OpcUaWriteOutcome(v.NodeId, 0u, null)
                        : new OpcUaWriteOutcome(v.NodeId, 0x80AE0000u, null))
                    .ToList()));

        var results = await _adapter.WriteBatchAsync(new Dictionary<string, object?>
        {
            ["tag1"] = 1,
            ["tag2"] = 2,
            ["tag3"] = 3
        });

        // Every requested tag is present in the result map — the batch was not aborted.
        Assert.Equal(3, results.Count);
        Assert.True(results["tag1"].Success);
        Assert.False(results["tag2"].Success);
        Assert.NotNull(results["tag2"].ErrorMessage);
        Assert.False(results["tag3"].Success);
        Assert.NotNull(results["tag3"].ErrorMessage);
        // ONE bulk write, not three single writes.
        Assert.Single(_mockClient.ReceivedCalls(), c => c.GetMethodInfo().Name == "WriteValuesAsync");
    }

    [Fact]
    public async Task DCL017_WriteBatch_CancellationAbortsWholeBatch()
    {
        // Companion guard: a cancelled batch must still abort as a whole — only
        // connection/device faults are demoted to per-tag results, never cancellation.
        _mockClient.IsConnected.Returns(true);
        await _adapter.ConnectAsync(new Dictionary<string, string>());

        using var cts = new CancellationTokenSource();
        cts.Cancel();
        _mockClient.WriteValuesAsync(
                Arg.Any<IReadOnlyList<(string NodeId, object? Value)>>(), Arg.Any<CancellationToken>())
            .Returns<IReadOnlyList<OpcUaWriteOutcome>>(_ => throw new OperationCanceledException());

        await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
            _adapter.WriteBatchAsync(new Dictionary<string, object?> { ["tag1"] = 1 }, cts.Token));
    }

    [Fact]
    public async Task NotConnected_ThrowsOnOperations()
    {
        _mockClient.IsConnected.Returns(false);

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            _adapter.ReadAsync("tag1"));
    }

    [Fact]
    public async Task DisposeAsync_CleansUp()
    {
        _mockClient.IsConnected.Returns(true);
        await _adapter.ConnectAsync(new Dictionary<string, string>());
        await _adapter.DisposeAsync();

        Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status);
    }

    // --- Configuration Parsing ---

    [Fact]
    public async Task Connect_ParsesAllConfigurationKeys()
    {
        _mockClient.IsConnected.Returns(true);

        await _adapter.ConnectAsync(new Dictionary<string, string>
        {
            ["EndpointUrl"] = "opc.tcp://myserver:4840",
            ["SessionTimeoutMs"] = "120000",
            ["OperationTimeoutMs"] = "30000",
            ["PublishingIntervalMs"] = "500",
            ["KeepAliveCount"] = "5",
            ["LifetimeCount"] = "15",
            ["MaxNotificationsPerPublish"] = "200",
            ["SamplingIntervalMs"] = "250",
            ["QueueSize"] = "20",
            ["SecurityMode"] = "SignAndEncrypt",
            ["AutoAcceptUntrustedCerts"] = "false"
        });

        await _mockClient.Received(1).ConnectAsync(
            "opc.tcp://myserver:4840",
            Arg.Is<OpcUaConnectionOptions?>(o =>
                o != null &&
                o.SessionTimeoutMs == 120000 &&
                o.OperationTimeoutMs == 30000 &&
                o.PublishingIntervalMs == 500 &&
                o.KeepAliveCount == 5 &&
                o.LifetimeCount == 15 &&
                o.MaxNotificationsPerPublish == 200 &&
                o.SamplingIntervalMs == 250 &&
                o.QueueSize == 20 &&
                o.SecurityMode == "SignAndEncrypt" &&
                o.AutoAcceptUntrustedCerts == false),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Connect_UsesDefaults_WhenKeysNotProvided()
    {
        _mockClient.IsConnected.Returns(true);

        await _adapter.ConnectAsync(new Dictionary<string, string>());

        await _mockClient.Received(1).ConnectAsync(
            "opc.tcp://localhost:4840",
            Arg.Is<OpcUaConnectionOptions?>(o =>
                o != null &&
                o.SessionTimeoutMs == 60000 &&
                o.OperationTimeoutMs == 15000 &&
                o.PublishingIntervalMs == 1000 &&
                o.KeepAliveCount == 10 &&
                o.LifetimeCount == 30 &&
                o.MaxNotificationsPerPublish == 100 &&
                o.SamplingIntervalMs == 1000 &&
                o.QueueSize == 10 &&
                o.SecurityMode == "None" &&
                o.AutoAcceptUntrustedCerts == true),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Connect_IgnoresInvalidNumericValues()
    {
        _mockClient.IsConnected.Returns(true);

        await _adapter.ConnectAsync(new Dictionary<string, string>
        {
            ["SessionTimeoutMs"] = "notanumber",
            ["OperationTimeoutMs"] = "",
            ["PublishingIntervalMs"] = "abc",
            ["QueueSize"] = "12.5"
        });

        await _mockClient.Received(1).ConnectAsync(
            Arg.Any<string>(),
            Arg.Is<OpcUaConnectionOptions?>(o =>
                o != null &&
                o.SessionTimeoutMs == 60000 &&
                o.OperationTimeoutMs == 15000 &&
                o.PublishingIntervalMs == 1000 &&
                o.QueueSize == 10),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Connect_ParsesSecurityMode()
    {
        _mockClient.IsConnected.Returns(true);

        await _adapter.ConnectAsync(new Dictionary<string, string>
        {
            ["SecurityMode"] = "Sign"
        });

        await _mockClient.Received(1).ConnectAsync(
            Arg.Any<string>(),
            Arg.Is<OpcUaConnectionOptions?>(o => o != null && o.SecurityMode == "Sign"),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Connect_ParsesAutoAcceptCerts()
    {
        _mockClient.IsConnected.Returns(true);

        await _adapter.ConnectAsync(new Dictionary<string, string>
        {
            ["AutoAcceptUntrustedCerts"] = "false"
        });

        await _mockClient.Received(1).ConnectAsync(
            Arg.Any<string>(),
            Arg.Is<OpcUaConnectionOptions?>(o => o != null && o.AutoAcceptUntrustedCerts == false),
            Arg.Any<CancellationToken>());
    }

    // DataConnectionLayer-S1: the adapter instance is reused across reconnects
    // (DataConnectionActor calls ConnectAsync again on auto-reconnect). Before the
    // fix, ConnectAsync overwrote _client without disposing/detaching the previous
    // one, leaking its session/keep-alive/socket per flap AND leaving its stale
    // ConnectionLost handler wired — so a late keep-alive death on the OLD client
    // flapped the NEW healthy session (after _disconnectFired was reset).
    [Fact]
    public async Task Reconnect_DisposesAndDetachesPreviousClient()
    {
        var clients = new List<IOpcUaClient>();
        _mockFactory.Create().Returns(_ =>
        {
            var c = Substitute.For<IOpcUaClient>();
            clients.Add(c);
            return c;
        });
        var details = new Dictionary<string, string> { ["EndpointUrl"] = "opc.tcp://x:4840" };

        await _adapter.ConnectAsync(details);   // client[0]
        await _adapter.ConnectAsync(details);   // reconnect -> client[1]

        Assert.Equal(2, clients.Count);
        await clients[0].Received(1).DisposeAsync();
        await clients[1].DidNotReceive().DisposeAsync();
    }

    [Fact]
    public async Task StaleClientConnectionLost_DoesNotRaiseDisconnected_AfterReconnect()
    {
        var clients = new List<IOpcUaClient>();
        _mockFactory.Create().Returns(_ =>
        {
            var c = Substitute.For<IOpcUaClient>();
            clients.Add(c);
            return c;
        });
        var details = new Dictionary<string, string> { ["EndpointUrl"] = "opc.tcp://x:4840" };
        var disconnects = 0;
        _adapter.Disconnected += () => Interlocked.Increment(ref disconnects);

        await _adapter.ConnectAsync(details);
        await _adapter.ConnectAsync(details);   // reconnect resets _disconnectFired

        // The OLD client's keep-alive dies later — must be a no-op now.
        clients[0].ConnectionLost += Raise.Event<Action>();
        Assert.Equal(0, disconnects);

        // The CURRENT client's loss must still signal.
        clients[1].ConnectionLost += Raise.Event<Action>();
        Assert.Equal(1, disconnects);
    }
}
