using System.Diagnostics;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
using MQTTnet;
using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;

/// <summary>
///     Covers <see cref="MqttConnection"/>'s two Task-3 responsibilities: the pure
///     <see cref="MqttConnection.BuildClientOptions"/> options assembly (TLS / CA pin /
///     credentials / protocol knobs) and the bounded <see cref="MqttConnection.ConnectAsync"/>
///     deadline.
/// </summary>
public sealed class MqttConnectionTests
{
    private const string Host = "broker.example.test";

    // ---------------------------------------------------------------------------------
    // Bounded connect (the R2-01 frozen-peer lesson: no unbounded wait on a dead broker)
    // ---------------------------------------------------------------------------------

    /// <summary>
    ///     A broker that completes the TCP handshake but never answers CONNACK is the shape that
    ///     actually wedges a client — a refused port returns ECONNREFUSED instantly and would pass
    ///     whether or not any deadline logic existed.
    /// </summary>
    [Fact]
    public async Task ConnectAsync_BlackholeBroker_FailsWithinDeadline_NoHang()
    {
        using var blackhole = new BlackholeBroker();
        var opts = new MqttDriverOptions
        {
            Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 2,
        };
        await using var conn = new MqttConnection(opts, driverId: "t", logger: null);

        var sw = Stopwatch.StartNew();
        await Should.ThrowAsync<Exception>(async () => await conn.ConnectAsync(CancellationToken.None));
        sw.Stop();

        sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
        conn.IsConnected.ShouldBeFalse();
    }

    /// <summary>
    ///     Falsifiability control for the linked-CTS wiring specifically: the configured connect
    ///     deadline is 60 s, so the only thing that can end this call inside 10 s is the caller's
    ///     token actually reaching MQTTnet. Drop the <c>CreateLinkedTokenSource</c> and this test
    ///     hangs for a minute.
    /// </summary>
    [Fact]
    public async Task ConnectAsync_CallerCancellation_ObservedLongBeforeConnectDeadline()
    {
        using var blackhole = new BlackholeBroker();
        var opts = new MqttDriverOptions
        {
            Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 60,
        };
        await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
        using var caller = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));

        var sw = Stopwatch.StartNew();
        await Should.ThrowAsync<OperationCanceledException>(async () => await conn.ConnectAsync(caller.Token));
        sw.Stop();

        sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
    }

    /// <summary>
    ///     The deadline leg is distinguishable from a caller cancellation — a timed-out connect
    ///     surfaces <see cref="TimeoutException"/>, not a bare <see cref="OperationCanceledException"/>,
    ///     so an operator can tell "broker never answered" from "we were asked to stop".
    /// </summary>
    [Fact]
    public async Task ConnectAsync_DeadlineElapsed_ThrowsTimeoutException()
    {
        using var blackhole = new BlackholeBroker();
        var opts = new MqttDriverOptions
        {
            Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1,
        };
        await using var conn = new MqttConnection(opts, driverId: "t", logger: null);

        await Should.ThrowAsync<TimeoutException>(async () => await conn.ConnectAsync(CancellationToken.None));
    }

    /// <summary>
    ///     Task 4's reconnect loop retries connect on the SAME instance, reusing the
    ///     <c>IMqttClient</c> held across failures. A failed attempt must therefore leave the
    ///     connection usable rather than poisoned.
    /// </summary>
    [Fact]
    public async Task ConnectAsync_CanBeRetriedOnTheSameInstanceAfterAFailedAttempt()
    {
        using var blackhole = new BlackholeBroker();
        var opts = new MqttDriverOptions
        {
            Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1,
        };
        await using var conn = new MqttConnection(opts, driverId: "t", logger: null);

        await Should.ThrowAsync<TimeoutException>(async () => await conn.ConnectAsync(CancellationToken.None));

        // Second attempt reaches the broker again and fails the same way — not ObjectDisposed,
        // not a null client, not a silently-swallowed no-op.
        await Should.ThrowAsync<TimeoutException>(async () => await conn.ConnectAsync(CancellationToken.None));
        conn.IsConnected.ShouldBeFalse();
    }

    /// <summary>
    ///     Connecting a disposed instance is an <see cref="ObjectDisposedException"/> — the same
    ///     classification the in-flight dispose race folds into. Dispose is idempotent.
    /// </summary>
    [Fact]
    public async Task ConnectAsync_AfterDispose_ThrowsObjectDisposed_AndDisposeIsIdempotent()
    {
        var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false, ConnectTimeoutSeconds = 1 };
        var conn = new MqttConnection(opts, driverId: "t", logger: null);

        await conn.DisposeAsync();
        await conn.DisposeAsync();

        await Should.ThrowAsync<ObjectDisposedException>(async () => await conn.ConnectAsync(CancellationToken.None));
    }

    /// <summary>Never log or throw credentials — the plan's cross-cutting secrets rule.</summary>
    [Fact]
    public async Task ConnectAsync_Failure_DoesNotLeakPasswordIntoExceptionMessage()
    {
        const string secret = "sup3r-s3cret-broker-pw";
        using var blackhole = new BlackholeBroker();
        var opts = new MqttDriverOptions
        {
            Host = "127.0.0.1",
            Port = blackhole.Port,
            UseTls = false,
            ConnectTimeoutSeconds = 1,
            Username = "svc",
            Password = secret,
        };
        await using var conn = new MqttConnection(opts, driverId: "t", logger: null);

        var ex = await Should.ThrowAsync<Exception>(async () => await conn.ConnectAsync(CancellationToken.None));

        ex.ToString().ShouldNotContain(secret);
    }

    // ---------------------------------------------------------------------------------
    // BuildClientOptions — pure assembly, no I/O
    // ---------------------------------------------------------------------------------

    [Fact]
    public void BuildClientOptions_UseTlsWithCaPin_SetsTlsAndInstallsChainValidator()
    {
        var opts = new MqttDriverOptions
        {
            Host = Host,
            Port = 8883,
            UseTls = true,
            AllowUntrustedServerCertificate = false,
            CaCertificatePath = "/tmp/ca.pem",
        };

        var tls = BuildTls(opts);

        tls.UseTls.ShouldBeTrue();
        tls.TargetHost.ShouldBe(Host);
        tls.AllowUntrustedCertificates.ShouldBeFalse();
        tls.IgnoreCertificateChainErrors.ShouldBeFalse();
        tls.CertificateValidationHandler.ShouldNotBeNull();
    }

    /// <summary>
    ///     The CA file is read lazily inside the validation callback, never by the builder — so a
    ///     path that does not exist yet must not throw at build time, and must fail the *connection*
    ///     closed when the handshake finally asks.
    /// </summary>
    [Fact]
    public void BuildClientOptions_CaPinPathMissing_BuildsWithoutIo_AndValidatorFailsClosed()
    {
        var missing = Path.Combine(Path.GetTempPath(), $"otopcua-mqtt-no-such-ca-{Guid.NewGuid():N}.pem");
        File.Exists(missing).ShouldBeFalse();

        var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = missing };

        var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
        var tls = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;

        tls.CertificateValidationHandler.ShouldNotBeNull();
        using var stranger = SelfSignedLeaf(Host);
        tls.CertificateValidationHandler(ValidationArgs(built, stranger)).ShouldBeFalse();
    }

    /// <summary>An untrusted, unpinned server certificate is rejected — the pin actually validates.</summary>
    [Fact]
    public void BuildClientOptions_CaPin_RejectsCertificateNotIssuedByThePinnedCa()
    {
        using var ca = IssueCa(issuer: null, "otopcua-test-ca", validDays: 30);
        var caPath = WritePem(ca);
        try
        {
            var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath };
            var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
            var tls = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;

            using var stranger = SelfSignedLeaf(Host);
            tls.CertificateValidationHandler!(ValidationArgs(built, stranger)).ShouldBeFalse();
        }
        finally
        {
            File.Delete(caPath);
        }
    }

    /// <summary>
    ///     The accept path. Without this every certificate test asserts rejection, so deleting the
    ///     <c>return true</c> — or getting <c>TrustMode</c> / <c>CustomTrustStore</c> wrong — would
    ///     leave the suite green while no broker could ever connect.
    /// </summary>
    [Fact]
    public void BuildClientOptions_CaPin_AcceptsLeafIssuedByThePinnedCa()
    {
        using var ca = IssueCa(issuer: null, "otopcua-test-ca", validDays: 30);
        using var leaf = IssueLeaf(ca, Host, validDays: 20);
        var caPath = WritePem(ca);
        try
        {
            var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath };
            var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
            var tls = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;

            tls.CertificateValidationHandler!(ValidationArgs(built, leaf)).ShouldBeTrue();
        }
        finally
        {
            File.Delete(caPath);
        }
    }

    /// <summary>
    ///     The common real-world broker topology: the leaf is signed by an intermediate that chains
    ///     to the pinned root, and the intermediate is delivered in the handshake rather than being
    ///     installed locally. Only the pinned ROOT is written to the CA file — the intermediate must
    ///     be picked up from what the broker presented.
    /// </summary>
    [Fact]
    public void BuildClientOptions_CaPin_AcceptsLeafBehindAnIntermediateThatTheBrokerPresented()
    {
        using var root = IssueCa(issuer: null, "otopcua-test-root", validDays: 30);
        using var intermediate = IssueCa(root, "otopcua-test-intermediate", validDays: 25);
        using var leaf = IssueLeaf(intermediate, Host, validDays: 20);
        var caPath = WritePem(root);
        try
        {
            var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath };
            var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
            var tls = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;

            tls.CertificateValidationHandler!(ValidationArgs(built, leaf, intermediate)).ShouldBeTrue();
        }
        finally
        {
            File.Delete(caPath);
        }
    }

    /// <summary>Default posture: OS trust store, no custom handler, escape hatch off.</summary>
    [Fact]
    public void BuildClientOptions_TlsWithoutCaPin_LeavesOsTrustStoreValidationInPlace()
    {
        var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true };

        var tls = BuildTls(opts);

        tls.UseTls.ShouldBeTrue();
        tls.CertificateValidationHandler.ShouldBeNull();
        tls.AllowUntrustedCertificates.ShouldBeFalse();
        tls.IgnoreCertificateChainErrors.ShouldBeFalse();
        tls.IgnoreCertificateRevocationErrors.ShouldBeFalse();
    }

    /// <summary>The dev/on-prem escape hatch only opens when explicitly asked for.</summary>
    [Fact]
    public void BuildClientOptions_AllowUntrustedServerCertificate_OpensEscapeHatch()
    {
        var opts = new MqttDriverOptions
        {
            Host = Host, Port = 8883, UseTls = true, AllowUntrustedServerCertificate = true,
        };

        var tls = BuildTls(opts);

        tls.AllowUntrustedCertificates.ShouldBeTrue();
        tls.IgnoreCertificateChainErrors.ShouldBeTrue();
        tls.CertificateValidationHandler.ShouldNotBeNull();
    }

    [Fact]
    public void BuildClientOptions_UseTlsFalse_KeepsTlsOffAndInstallsNoValidator()
    {
        var opts = new MqttDriverOptions
        {
            Host = Host, Port = 1883, UseTls = false, AllowUntrustedServerCertificate = true, CaCertificatePath = "/tmp/ca.pem",
        };

        var tls = BuildTls(opts);

        tls.UseTls.ShouldBeFalse();
        tls.CertificateValidationHandler.ShouldBeNull();
        tls.AllowUntrustedCertificates.ShouldBeFalse();
    }

    [Fact]
    public void BuildClientOptions_MapsProtocolAndSessionKnobs()
    {
        var opts = new MqttDriverOptions
        {
            Host = Host,
            Port = 8883,
            ClientId = "otopcua-node-1",
            ProtocolVersion = MqttProtocolVersion.V311,
            CleanSession = false,
            KeepAliveSeconds = 45,
            ConnectTimeoutSeconds = 7,
        };

        var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);

        built.ProtocolVersion.ShouldBe(MQTTnet.Formatter.MqttProtocolVersion.V311);
        built.ClientId.ShouldBe("otopcua-node-1");
        built.CleanSession.ShouldBeFalse();
        built.KeepAlivePeriod.ShouldBe(TimeSpan.FromSeconds(45));
        built.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
        var tcp = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>();
        tcp.RemoteEndpoint.ShouldBeOfType<DnsEndPoint>().Host.ShouldBe(Host);
        tcp.RemoteEndpoint.ShouldBeOfType<DnsEndPoint>().Port.ShouldBe(8883);
    }

    [Fact]
    public void BuildClientOptions_ClientIdSuffix_IsAppended()
    {
        var opts = new MqttDriverOptions { Host = Host, ClientId = "otopcua" };

        MqttConnection.BuildClientOptions(opts, clientIdSuffix: "-browse").ClientId.ShouldBe("otopcua-browse");
    }

    [Fact]
    public void BuildClientOptions_NoUsername_SendsNoCredentials()
        => MqttConnection.BuildClientOptions(new MqttDriverOptions { Host = Host }, clientIdSuffix: null)
            .Credentials.ShouldBeNull();

    [Fact]
    public void BuildClientOptions_Username_SendsCredentials()
    {
        var opts = new MqttDriverOptions { Host = Host, Username = "svc", Password = "pw" };

        var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);

        built.Credentials.ShouldNotBeNull();
        built.Credentials.GetUserName(built).ShouldBe("svc");
    }

    /// <summary>The ctor must not touch the network — the universal-browser throwaway-instance pattern.</summary>
    [Fact]
    public void Ctor_IsConnectionFree()
    {
        var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false };

        var conn = new MqttConnection(opts, driverId: "t", logger: null);

        conn.IsConnected.ShouldBeFalse();
    }

    // ---------------------------------------------------------------------------------
    // Task 4 — NextBackoff (pure): bounded, monotone, cannot hot-loop, cannot overflow
    // ---------------------------------------------------------------------------------

    [Theory]
    [InlineData(0, 1)]
    [InlineData(1, 2)]
    [InlineData(2, 4)]
    [InlineData(3, 8)]
    [InlineData(4, 16)]
    [InlineData(5, 30)] // 32 → clamped
    [InlineData(10, 30)]
    public void NextBackoff_IsExponentialClampedToMax(int attempt, int expectedSeconds)
        => MqttConnection.NextBackoff(attempt, minSeconds: 1, maxSeconds: 30)
            .ShouldBe(TimeSpan.FromSeconds(expectedSeconds));

    /// <summary>
    ///     A shift-based implementation (<c>min &lt;&lt; attempt</c>) wraps: <c>1 &lt;&lt; 32</c> is
    ///     <c>1</c>, not 4294967296, so a broker that has been down for half an hour would suddenly
    ///     be hammered once a second. The calculator must saturate, not wrap.
    /// </summary>
    [Fact]
    public void NextBackoff_HugeAttemptCount_SaturatesAtMax_NeverWrapsBackToMin()
    {
        foreach (var attempt in new[] { 31, 32, 33, 40, 62, 63, 64, 1_000, int.MaxValue })
        {
            MqttConnection.NextBackoff(attempt, minSeconds: 1, maxSeconds: 30)
                .ShouldBe(TimeSpan.FromSeconds(30), $"attempt {attempt}");
        }
    }

    [Fact]
    public void NextBackoff_IsMonotoneNonDecreasing_AndAlwaysWithinMinMax()
    {
        var previous = TimeSpan.Zero;
        for (var attempt = 0; attempt <= 64; attempt++)
        {
            var backoff = MqttConnection.NextBackoff(attempt, minSeconds: 2, maxSeconds: 30);

            backoff.ShouldBeGreaterThanOrEqualTo(previous, $"attempt {attempt} went backwards");
            backoff.ShouldBeGreaterThanOrEqualTo(TimeSpan.FromSeconds(2), $"attempt {attempt} below min");
            backoff.ShouldBeLessThanOrEqualTo(TimeSpan.FromSeconds(30), $"attempt {attempt} above max");
            previous = backoff;
        }
    }

    /// <summary>
    ///     A misconfigured <c>max &lt; min</c> must not collapse the floor — the floor is the only
    ///     thing standing between a dead broker and a hot reconnect loop.
    /// </summary>
    [Fact]
    public void NextBackoff_MaxBelowMin_HonoursMin_SoTheLoopCannotGoHot()
        => MqttConnection.NextBackoff(0, minSeconds: 10, maxSeconds: 5).ShouldBe(TimeSpan.FromSeconds(10));

    [Fact]
    public void NextBackoff_NonPositiveAttempt_IsTheFirstAttemptDelay()
    {
        MqttConnection.NextBackoff(0, minSeconds: 3, maxSeconds: 30).ShouldBe(TimeSpan.FromSeconds(3));
        MqttConnection.NextBackoff(-5, minSeconds: 3, maxSeconds: 30).ShouldBe(TimeSpan.FromSeconds(3));
    }

    // ---------------------------------------------------------------------------------
    // Task 4 — the Reconnected callback (the load-bearing re-subscribe hook)
    // ---------------------------------------------------------------------------------

    [Fact]
    public async Task FireReconnected_InvokesEverySubscribedCallback()
    {
        await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null);
        var first = 0;
        var second = 0;
        conn.Reconnected += _ => { Interlocked.Increment(ref first); return Task.CompletedTask; };
        conn.Reconnected += _ => { Interlocked.Increment(ref second); return Task.CompletedTask; };

        await conn.FireReconnectedAsync(TestContext.Current.CancellationToken);

        first.ShouldBe(1);
        second.ShouldBe(1);
    }

    /// <summary>
    ///     Awaiting a multicast <c>Func&lt;Task&gt;</c> directly returns only the LAST handler's task
    ///     and abandons the earlier ones — so one throwing subscriber would silently skip every
    ///     subscriber behind it. The fan-out must walk the invocation list and still surface the
    ///     failure, because a re-subscribe that failed is a connection that has gone dark.
    /// </summary>
    [Fact]
    public async Task FireReconnected_OneHandlerThrows_StillInvokesTheRest_AndSurfacesTheFailure()
    {
        await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null);
        var behindTheThrower = 0;
        conn.Reconnected += _ => throw new InvalidOperationException("SUBACK refused");
        conn.Reconnected += _ => { Interlocked.Increment(ref behindTheThrower); return Task.CompletedTask; };

        await Should.ThrowAsync<Exception>(async () => await conn.FireReconnectedAsync(TestContext.Current.CancellationToken));

        behindTheThrower.ShouldBe(1);
    }

    [Fact]
    public async Task FireReconnected_NoSubscribers_IsANoOp()
    {
        await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null);

        await Should.NotThrowAsync(async () => await conn.FireReconnectedAsync(TestContext.Current.CancellationToken));
    }

    // ---------------------------------------------------------------------------------
    // Task 4 — State + LastMessage
    // ---------------------------------------------------------------------------------

    [Fact]
    public void State_AfterCtor_IsDisconnected()
        => new MqttConnection(LoopbackOpts(), driverId: "t", logger: null)
            .State.ShouldBe(MqttConnectionState.Disconnected);

    [Fact]
    public async Task State_AfterDispose_IsDisposed()
    {
        var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null);

        await conn.DisposeAsync();

        conn.State.ShouldBe(MqttConnectionState.Disposed);
    }

    /// <summary>
    ///     Faulted is reserved for config that cannot succeed no matter how long we retry. A broker
    ///     that is merely down is <see cref="MqttConnectionState.Reconnecting"/>, indefinitely.
    /// </summary>
    [Fact]
    public async Task State_UnrecoverableProtocolConfig_IsFaulted()
    {
        var opts = new MqttDriverOptions
        {
            Host = "127.0.0.1",
            Port = 1,
            UseTls = false,
            ConnectTimeoutSeconds = 1,
            ProtocolVersion = (MqttProtocolVersion)99,
        };
        await using var conn = new MqttConnection(opts, driverId: "t", logger: null);

        await Should.ThrowAsync<ArgumentOutOfRangeException>(async () => await conn.ConnectAsync(CancellationToken.None));

        conn.State.ShouldBe(MqttConnectionState.Faulted);
    }

    /// <summary>A broker that is simply unreachable is retryable, never <c>Faulted</c>.</summary>
    [Fact]
    public async Task State_UnreachableBroker_IsNotFaulted()
    {
        using var blackhole = new BlackholeBroker();
        var opts = new MqttDriverOptions
        {
            Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1,
        };
        await using var conn = new MqttConnection(opts, driverId: "t", logger: null);

        await Should.ThrowAsync<TimeoutException>(async () => await conn.ConnectAsync(CancellationToken.None));

        conn.State.ShouldNotBe(MqttConnectionState.Faulted);
    }

    [Fact]
    public void LastMessage_BeforeAnyTraffic_IsNull()
    {
        var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null);

        conn.LastMessageUtc.ShouldBeNull();
        conn.LastMessageAge.ShouldBeNull();
    }

    /// <summary>
    ///     A failed FIRST connect is the caller's problem (driver-host resilience retries
    ///     <c>InitializeAsync</c>) — it must not silently leave a background reconnect worker
    ///     hammering an endpoint the caller has already given up on.
    /// </summary>
    [Fact]
    public async Task ConnectAsync_InitialAttemptFails_LeavesNoBackgroundReconnectStorm()
    {
        using var blackhole = new BlackholeBroker();
        var opts = new MqttDriverOptions
        {
            Host = "127.0.0.1",
            Port = blackhole.Port,
            UseTls = false,
            ConnectTimeoutSeconds = 1,
            ReconnectMinBackoffSeconds = 1,
            ReconnectMaxBackoffSeconds = 1,
        };
        await using var conn = new MqttConnection(opts, driverId: "t", logger: null);

        await Should.ThrowAsync<TimeoutException>(async () => await conn.ConnectAsync(CancellationToken.None));
        var acceptedAfterTheAttempt = blackhole.AcceptedCount;

        await Task.Delay(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);

        blackhole.AcceptedCount.ShouldBe(acceptedAfterTheAttempt);
    }

    // ---------------------------------------------------------------------------------
    // Task 4 — the reconnect supervisor, against a real (minimal) broker
    // ---------------------------------------------------------------------------------

    /// <summary>
    ///     THE load-bearing invariant. MQTT subscriptions do not survive a clean session, so a
    ///     reconnect that completes without firing <c>Reconnected</c> leaves a healthy-looking
    ///     connection that receives nothing, forever, with no error to show for it. Driven through
    ///     the real socket → real MQTTnet <c>DisconnectedAsync</c> → real re-CONNECT path, not a
    ///     test seam.
    /// </summary>
    [Fact]
    public async Task BrokerDropsTheConnection_ReconnectsAndFiresReconnected()
    {
        using var broker = new MiniBroker();
        await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);
        var resubscribes = 0;
        conn.Reconnected += _ => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; };

        await conn.ConnectAsync(CancellationToken.None);

        conn.State.ShouldBe(MqttConnectionState.Connected);
        conn.IsConnected.ShouldBeTrue();
        Volatile.Read(ref resubscribes).ShouldBe(0, "the initial connect is not a reconnect");

        broker.DropAll();

        (await WaitUntilAsync(() => Volatile.Read(ref resubscribes) >= 1, TimeSpan.FromSeconds(30)))
            .ShouldBeTrue("the reconnect never fired Reconnected — subscriptions would be silently gone");
        (await WaitUntilAsync(() => conn.IsConnected, TimeSpan.FromSeconds(10))).ShouldBeTrue();
        conn.State.ShouldBe(MqttConnectionState.Connected);
        broker.AcceptedCount.ShouldBeGreaterThanOrEqualTo(2);
    }

    /// <summary>
    ///     A broker that stays down keeps the supervisor in <c>Reconnecting</c> indefinitely (never
    ///     <c>Faulted</c>) — and the backoff keeps the retry rate low enough that a handful of
    ///     attempts, not hundreds, land in the observation window.
    /// </summary>
    [Fact]
    public async Task BrokerStaysDown_RetriesUnderBackoff_WithoutHotLooping()
    {
        using var broker = new MiniBroker();
        var opts = BrokerOpts(broker.Port) with
        {
            ConnectTimeoutSeconds = 1, ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 2,
        };
        await using var conn = new MqttConnection(opts, driverId: "t", logger: null);

        await conn.ConnectAsync(CancellationToken.None);
        var acceptedWhileHealthy = broker.AcceptedCount;

        broker.Blackhole = true; // accepts TCP, never answers CONNACK again
        broker.DropAll();

        (await WaitUntilAsync(() => conn.State == MqttConnectionState.Reconnecting, TimeSpan.FromSeconds(10)))
            .ShouldBeTrue();
        await Task.Delay(TimeSpan.FromSeconds(8), TestContext.Current.CancellationToken);

        conn.State.ShouldBe(MqttConnectionState.Reconnecting, "a down broker is retryable, not Faulted");
        var retries = broker.AcceptedCount - acceptedWhileHealthy;
        retries.ShouldBeGreaterThanOrEqualTo(1, "the supervisor gave up on a down broker");
        retries.ShouldBeLessThan(15, $"backoff is not bounding the retry rate ({retries} attempts in ~8s)");
    }

    /// <summary>
    ///     Dispose during a reconnect backoff must stop the supervisor dead — no leaked task, no
    ///     later attempt resurrecting a connection the caller has torn down.
    /// </summary>
    [Fact]
    public async Task Dispose_DuringReconnectBackoff_StopsTheSupervisor_NoFurtherAttempts()
    {
        using var broker = new MiniBroker();
        var opts = BrokerOpts(broker.Port) with
        {
            ConnectTimeoutSeconds = 1, ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 2,
        };
        var conn = new MqttConnection(opts, driverId: "t", logger: null);

        await conn.ConnectAsync(CancellationToken.None);
        broker.Blackhole = true;
        broker.DropAll();
        (await WaitUntilAsync(() => conn.State == MqttConnectionState.Reconnecting, TimeSpan.FromSeconds(10)))
            .ShouldBeTrue();

        await conn.DisposeAsync();
        var acceptedAtDispose = broker.AcceptedCount;

        conn.State.ShouldBe(MqttConnectionState.Disposed);
        conn.IsConnected.ShouldBeFalse();
        await Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
        broker.AcceptedCount.ShouldBe(acceptedAtDispose, "the supervisor outlived DisposeAsync");
    }

    /// <summary>
    ///     The T3 connect/dispose leak, reproduced at the exact interleaving the reviewer traced:
    ///     the client has already CONNECTED when dispose flags the instance and blocks on the
    ///     lifecycle gate this connect is holding — so dispose disposes nothing. Without the
    ///     post-connect re-check the client stays live with an open socket that no later
    ///     <c>DisposeAsync</c> can ever reach; the broker sees the leak as a connection that never
    ///     closes.
    /// </summary>
    [Fact]
    public async Task DisposeRacingAnInFlightConnect_LeavesNoLiveBrokerConnection()
    {
        using var broker = new MiniBroker();
        var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);

        // No assertions inside the hook: it runs inside the production try, so a Shouldly throw would
        // be laundered through Classify and resurface as a confusing TimeoutException. Record, assert
        // outside.
        var raceReproduced = false;
        conn.AfterConnectHookForTests = async () =>
        {
            _ = Task.Run(async () => await conn.DisposeAsync());
            raceReproduced = await WaitUntilAsync(
                () => conn.State == MqttConnectionState.Disposed,
                TimeSpan.FromSeconds(5));
        };

        await Should.ThrowAsync<ObjectDisposedException>(async () => await conn.ConnectAsync(CancellationToken.None));

        raceReproduced.ShouldBeTrue("dispose never flagged the instance — the race was not reproduced");
        (await WaitUntilAsync(() => broker.LiveConnections == 0, TimeSpan.FromSeconds(15)))
            .ShouldBeTrue($"a live broker connection leaked ({broker.LiveConnections} still open)");
        conn.IsConnected.ShouldBeFalse();
    }

    // ---------------------------------------------------------------------------------
    // C1 — connect-vs-connect: the supervisor and ConnectAsync must not corrupt each other
    // ---------------------------------------------------------------------------------

    /// <summary>
    ///     Direction (b), the simplest form: MQTTnet throws
    ///     <c>InvalidOperationException: It is not allowed to connect with a server after the
    ///     connection is established</c> for a connect-while-connected. That is outside this type's
    ///     documented contract, so a caller re-running <c>InitializeAsync</c> against a healthy
    ///     session would look like a hard driver failure.
    /// </summary>
    [Fact]
    public async Task ConnectAsync_OnAnAlreadyEstablishedSession_IsANoOp_NotAnInvalidOperation()
    {
        using var broker = new MiniBroker();
        await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);

        await conn.ConnectAsync(CancellationToken.None);
        await Should.NotThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None));

        conn.IsConnected.ShouldBeTrue();
        conn.State.ShouldBe(MqttConnectionState.Connected);
        broker.AcceptedCount.ShouldBe(1, "the second connect opened a second socket instead of no-opping");
    }

    /// <summary>
    ///     Direction (b) through the supervisor: the supervisor restores the session, then the driver
    ///     host re-runs <c>InitializeAsync</c> — exactly the sequence this design relies on.
    /// </summary>
    [Fact]
    public async Task ConnectAsync_AfterTheSupervisorAlreadyReconnected_IsANoOp()
    {
        using var broker = new MiniBroker();
        await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);
        var resubscribes = 0;
        conn.Reconnected += _ => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; };

        await conn.ConnectAsync(CancellationToken.None);
        broker.DropAll();
        (await WaitUntilAsync(
                () => Volatile.Read(ref resubscribes) >= 1 && conn.State == MqttConnectionState.Connected,
                TimeSpan.FromSeconds(30)))
            .ShouldBeTrue();

        await Should.NotThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None));

        conn.IsConnected.ShouldBeTrue();
        conn.State.ShouldBe(MqttConnectionState.Connected);
    }

    /// <summary>
    ///     Direction (a): a caller reconnects while the supervisor is asleep in backoff. The
    ///     supervisor must find the restored session and stand down — <b>silently</b>, without firing
    ///     <c>Reconnected</c> (the caller owns its own re-subscribe). Left unfixed it fails forever
    ///     against a perfectly healthy connection, at Debug level, inflating <c>attempt</c> so the
    ///     NEXT genuine outage waits max backoff instead of min.
    /// </summary>
    [Fact]
    public async Task CallerReconnectsWhileTheSupervisorIsInBackoff_SupervisorStandsDownSilently()
    {
        using var broker = new MiniBroker();
        var log = new CapturingLogger();
        var opts = BrokerOpts(broker.Port) with { ReconnectMinBackoffSeconds = 3, ReconnectMaxBackoffSeconds = 3 };
        await using var conn = new MqttConnection(opts, driverId: "t", logger: log);
        var resubscribes = 0;
        conn.Reconnected += _ => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; };

        await conn.ConnectAsync(CancellationToken.None);
        broker.DropAll();
        (await WaitUntilAsync(() => conn.State == MqttConnectionState.Reconnecting, TimeSpan.FromSeconds(10)))
            .ShouldBeTrue();

        // The caller wins the race while the supervisor sleeps off its 3 s backoff.
        await conn.ConnectAsync(CancellationToken.None);
        conn.IsConnected.ShouldBeTrue();
        var failuresBefore = log.CountContaining("reconnect attempt");

        // Three more supervisor wake points would land in this window if it were still looping.
        await Task.Delay(TimeSpan.FromSeconds(9), TestContext.Current.CancellationToken);

        log.CountContaining("reconnect attempt")
            .ShouldBe(failuresBefore, "the supervisor kept failing against a healthy connection");
        conn.State.ShouldBe(MqttConnectionState.Connected);
        conn.IsConnected.ShouldBeTrue();
        Volatile.Read(ref resubscribes).ShouldBe(0, "the caller owns its own re-subscribe; Reconnected must not fire");
    }

    /// <summary>
    ///     The operational consequence of direction (a), measured rather than argued: after a caller
    ///     has won a reconnect race, the supervisor must be parked at its outer wait with its attempt
    ///     counter reset, so the NEXT genuine outage recovers at MIN backoff.
    /// </summary>
    /// <remarks>
    ///     The timings are load-bearing, not arbitrary. A supervisor still flailing against the healthy
    ///     connection would have burned attempts 0 and 1 (at t≈2 s and t≈6 s) and be asleep in an 8 s
    ///     backoff running to t≈14 s, so the outage induced at t≈7 s cannot be answered before then.
    ///     A stood-down supervisor answers it one min-backoff (2 s) later. The 5 s window separates the
    ///     two by a clear margin; widening the burn only widens the separation.
    /// </remarks>
    [Fact]
    public async Task AfterACallerWinsTheRace_TheNextGenuineOutageStillRecoversAtMinBackoff()
    {
        using var broker = new MiniBroker();
        var opts = BrokerOpts(broker.Port) with { ReconnectMinBackoffSeconds = 2, ReconnectMaxBackoffSeconds = 30 };
        await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
        var resubscribes = 0;
        conn.Reconnected += _ => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; };

        await conn.ConnectAsync(CancellationToken.None);
        broker.DropAll();
        (await WaitUntilAsync(() => conn.State == MqttConnectionState.Reconnecting, TimeSpan.FromSeconds(10)))
            .ShouldBeTrue();
        await conn.ConnectAsync(CancellationToken.None); // caller wins the race
        await Task.Delay(TimeSpan.FromSeconds(7), TestContext.Current.CancellationToken); // a broken loop climbs here

        broker.DropAll(); // the NEXT genuine outage
        var recovered = await WaitUntilAsync(() => Volatile.Read(ref resubscribes) >= 1, TimeSpan.FromSeconds(5));

        recovered.ShouldBeTrue("recovery took far longer than min backoff — the attempt counter never reset");
        conn.State.ShouldBe(MqttConnectionState.Connected);
    }

    // ---------------------------------------------------------------------------------
    // I2 / M3 — the Reconnected callback is bounded, and Connected means "connected AND subscribed"
    // ---------------------------------------------------------------------------------

    [Fact]
    public async Task FireReconnected_PassesTheSuppliedTokenToEverySubscriber()
    {
        await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null);
        using var cts = new CancellationTokenSource();
        var seen = new List<CancellationToken>();
        conn.Reconnected += ct => { seen.Add(ct); return Task.CompletedTask; };
        conn.Reconnected += ct => { seen.Add(ct); return Task.CompletedTask; };

        await conn.FireReconnectedAsync(cts.Token);

        seen.Count.ShouldBe(2);
        seen.ShouldAllBe(t => t == cts.Token);
    }

    /// <summary>
    ///     A re-subscribe that hangs — broker accepts SUBSCRIBE and never SUBACKs, the frozen-peer
    ///     shape again — must not park the supervisor. This subscriber deliberately <b>ignores</b> its
    ///     token, so only the fan-out's own ceiling can end the wait.
    /// </summary>
    /// <remarks>
    ///     Asserted at the transport, not at dispose timing: with the ceiling, each hung re-subscribe
    ///     is abandoned, the session torn down, and another CONNECT reaches the broker; without it,
    ///     the supervisor is parked forever on the first one and the broker never sees another. A
    ///     dispose-elapsed assertion cannot tell those apart — <c>DisposeAsync</c>'s own bounded join
    ///     returns promptly either way, leaving the supervisor alive behind it.
    /// </remarks>
    [Fact]
    public async Task ReSubscribeThatHangsIgnoringItsToken_DoesNotParkTheSupervisor()
    {
        using var broker = new MiniBroker();
        var opts = BrokerOpts(broker.Port) with
        {
            ConnectTimeoutSeconds = 2, ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 2,
        };
        var conn = new MqttConnection(opts, driverId: "t", logger: null);
        var entered = new TaskCompletionSource();
        conn.Reconnected += async _ =>
        {
            entered.TrySetResult();
            await Task.Delay(Timeout.InfiniteTimeSpan); // no token — only the ceiling bounds this
        };

        await conn.ConnectAsync(CancellationToken.None);
        broker.DropAll();
        await entered.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);

        // The first reconnect is accept #2; a supervisor that recovered from the hang makes at least
        // one more. A parked one never does.
        (await WaitUntilAsync(() => broker.AcceptedCount >= 3, TimeSpan.FromSeconds(25)))
            .ShouldBeTrue($"the supervisor is parked behind a hung re-subscribe ({broker.AcceptedCount} connects seen)");

        var sw = Stopwatch.StartNew();
        await conn.DisposeAsync();
        sw.Stop();

        sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(20), "dispose was parked behind a hung re-subscribe");
        conn.State.ShouldBe(MqttConnectionState.Disposed);
        var acceptedAtDispose = broker.AcceptedCount;
        await Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
        broker.AcceptedCount.ShouldBe(acceptedAtDispose, "the supervisor outlived DisposeAsync");
    }

    /// <summary>
    ///     M3: on the reconnect path the socket comes up before the subscriptions do. Publishing
    ///     <c>Connected</c> in that window advertises exactly the healthy-looking-but-deaf condition
    ///     this type exists to prevent. It is also the documented case where <c>State</c> and
    ///     <c>IsConnected</c> legitimately disagree.
    /// </summary>
    [Fact]
    public async Task Reconnect_DoesNotPublishConnectedUntilTheReSubscribeHasCompleted()
    {
        using var broker = new MiniBroker();
        await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);
        var inSubscriber = new TaskCompletionSource();
        var release = new TaskCompletionSource();
        conn.Reconnected += async _ =>
        {
            inSubscriber.TrySetResult();
            await release.Task;
        };

        await conn.ConnectAsync(CancellationToken.None);
        broker.DropAll();
        await inSubscriber.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);

        conn.IsConnected.ShouldBeTrue("the socket is up");
        conn.State.ShouldBe(MqttConnectionState.Reconnecting, "…but it has no subscriptions yet");

        release.SetResult();

        (await WaitUntilAsync(() => conn.State == MqttConnectionState.Connected, TimeSpan.FromSeconds(10)))
            .ShouldBeTrue();
    }

    // ---------------------------------------------------------------------------------
    // Refused CONNACK — MQTTnet 5 reports it as a RESULT, never an exception
    // ---------------------------------------------------------------------------------

    /// <summary>
    ///     The Task-13 live-gate defect, reproduced offline: Mosquitto genuinely rejected a wrong
    ///     password (<c>Client … disconnected, not authorised.</c>) and <c>ConnectAsync</c> returned
    ///     normally, because MQTTnet 5 hands a refused CONNACK back in
    ///     <c>MqttClientConnectResult.ResultCode</c> and v5 removed v4's
    ///     <c>ThrowOnNonSuccessfulConnectResponse</c> option.
    /// </summary>
    [Fact]
    public async Task ConnectAsync_BrokerRefusesTheCredentials_Throws_AndFaults()
    {
        using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeNotAuthorized };
        await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);

        var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
            async () => await conn.ConnectAsync(CancellationToken.None));

        ex.ResultCode.ShouldBe(MqttClientConnectResultCode.NotAuthorized);
        ex.IsUnrecoverable.ShouldBeTrue();
        ex.Message.ShouldContain("rejected the credentials");
        conn.IsConnected.ShouldBeFalse();
        conn.State.ShouldBe(MqttConnectionState.Faulted);
    }

    /// <summary>Credentials must never reach the message an operator (or a log sink) will see.</summary>
    [Fact]
    public async Task ConnectAsync_RefusedCredentials_DoesNotLeakThePasswordIntoTheRejection()
    {
        const string secret = "sup3r-s3cret-broker-pw";
        using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeBadUserNameOrPassword };
        var opts = BrokerOpts(broker.Port) with { Username = "svc", Password = secret };
        await using var conn = new MqttConnection(opts, driverId: "t", logger: null);

        var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
            async () => await conn.ConnectAsync(CancellationToken.None));

        ex.ToString().ShouldNotContain(secret);
    }

    /// <summary>
    ///     An unrecoverable rejection must not leave a supervisor grinding away every backoff period
    ///     against a broker that will never accept these credentials.
    /// </summary>
    [Fact]
    public async Task ConnectAsync_RefusedCredentials_StartsNoReconnectSupervisor()
    {
        using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeNotAuthorized };
        var opts = BrokerOpts(broker.Port) with { ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 1 };
        await using var conn = new MqttConnection(opts, driverId: "t", logger: null);

        await Should.ThrowAsync<MqttConnectRejectedException>(
            async () => await conn.ConnectAsync(CancellationToken.None));
        var acceptedAfterTheAttempt = broker.AcceptedCount;

        await Task.Delay(TimeSpan.FromSeconds(4), TestContext.Current.CancellationToken);

        broker.AcceptedCount.ShouldBe(acceptedAfterTheAttempt, "a hopeless rejection is being retried");
        conn.State.ShouldBe(MqttConnectionState.Faulted);
    }

    /// <summary>
    ///     A genuinely transient refusal is NOT a fault. Faulting on <c>ServerUnavailable</c> would take
    ///     a recoverable driver down until someone redeployed it.
    /// </summary>
    [Fact]
    public async Task ConnectAsync_TransientRefusal_Throws_ButStaysRetryable()
    {
        using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeServerUnavailable };
        await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);

        var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
            async () => await conn.ConnectAsync(CancellationToken.None));

        ex.ResultCode.ShouldBe(MqttClientConnectResultCode.ServerUnavailable);
        ex.IsUnrecoverable.ShouldBeFalse();
        conn.State.ShouldNotBe(MqttConnectionState.Faulted);
    }

    [Theory]
    [InlineData(MqttClientConnectResultCode.NotAuthorized, true)]
    [InlineData(MqttClientConnectResultCode.BadUserNameOrPassword, true)]
    [InlineData(MqttClientConnectResultCode.ClientIdentifierNotValid, true)]
    [InlineData(MqttClientConnectResultCode.UnsupportedProtocolVersion, true)]
    [InlineData(MqttClientConnectResultCode.Banned, true)]
    [InlineData(MqttClientConnectResultCode.BadAuthenticationMethod, true)]
    [InlineData(MqttClientConnectResultCode.TopicNameInvalid, true)]
    [InlineData(MqttClientConnectResultCode.QoSNotSupported, true)]
    // MQTT 5: 0x9D "permanently use another server" vs 0x9C "temporarily use another server".
    [InlineData(MqttClientConnectResultCode.ServerMoved, true)]
    [InlineData(MqttClientConnectResultCode.UseAnotherServer, false)]
    [InlineData(MqttClientConnectResultCode.ServerUnavailable, false)]
    [InlineData(MqttClientConnectResultCode.ServerBusy, false)]
    [InlineData(MqttClientConnectResultCode.QuotaExceeded, false)]
    [InlineData(MqttClientConnectResultCode.ConnectionRateExceeded, false)]
    // Carries no actionable information, so it must not be allowed to permanently fault a driver.
    [InlineData(MqttClientConnectResultCode.UnspecifiedError, false)]
    [InlineData(MqttClientConnectResultCode.ImplementationSpecificError, false)]
    public void IsUnrecoverableConnackRejection_ClassifiesByWhetherRetryingCouldEverHelp(
        MqttClientConnectResultCode code,
        bool expected)
        => MqttConnection.IsUnrecoverableConnackRejection(code).ShouldBe(expected);

    /// <summary>
    ///     A reason code this build has never seen must default to retryable — see the classification
    ///     remarks for why the ambiguous case fails toward availability, not toward Faulted.
    /// </summary>
    [Fact]
    public void IsUnrecoverableConnackRejection_UnknownCode_DefaultsToRetryable()
        => MqttConnection.IsUnrecoverableConnackRejection((MqttClientConnectResultCode)250).ShouldBeFalse();

    /// <summary>
    ///     The supervisor's own attempts go through the same CONNACK check. A broker that starts
    ///     refusing mid-life (credentials revoked, client banned) must stop the loop, not feed it.
    /// </summary>
    [Fact]
    public async Task Reconnect_BrokerStartsRefusing_FaultsAndStopsTheSupervisor()
    {
        using var broker = new MiniBroker();
        var opts = BrokerOpts(broker.Port) with { ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 1 };
        await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
        var resubscribes = 0;
        conn.Reconnected += _ => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; };

        await conn.ConnectAsync(CancellationToken.None);
        broker.ConnAckReturnCode = MiniBroker.ReturnCodeNotAuthorized; // credentials revoked
        broker.DropAll();

        (await WaitUntilAsync(() => conn.State == MqttConnectionState.Faulted, TimeSpan.FromSeconds(20)))
            .ShouldBeTrue("a revoked-credentials reconnect must fault, not retry forever");
        var acceptedAtFault = broker.AcceptedCount;
        await Task.Delay(TimeSpan.FromSeconds(4), TestContext.Current.CancellationToken);

        broker.AcceptedCount.ShouldBe(acceptedAtFault, "the supervisor kept retrying a hopeless rejection");
        Volatile.Read(ref resubscribes).ShouldBe(0, "nothing was ever re-subscribed on a refused session");
    }

    /// <summary>The mirror case: a transient refusal keeps the supervisor retrying under backoff.</summary>
    [Fact]
    public async Task Reconnect_TransientRefusal_KeepsRetryingUnderBackoff()
    {
        using var broker = new MiniBroker();
        var opts = BrokerOpts(broker.Port) with { ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 2 };
        await using var conn = new MqttConnection(opts, driverId: "t", logger: null);

        await conn.ConnectAsync(CancellationToken.None);
        var acceptedWhileHealthy = broker.AcceptedCount;
        broker.ConnAckReturnCode = MiniBroker.ReturnCodeServerUnavailable;
        broker.DropAll();

        (await WaitUntilAsync(() => broker.AcceptedCount >= acceptedWhileHealthy + 2, TimeSpan.FromSeconds(20)))
            .ShouldBeTrue("the supervisor gave up on a transiently-unavailable broker");
        conn.State.ShouldBe(MqttConnectionState.Reconnecting, "a busy broker is retryable, not Faulted");

        // And it recovers on its own once the broker starts accepting again.
        broker.ConnAckReturnCode = MiniBroker.ReturnCodeAccepted;
        (await WaitUntilAsync(() => conn.State == MqttConnectionState.Connected, TimeSpan.FromSeconds(20)))
            .ShouldBeTrue();
    }

    // ---------------------------------------------------------------------------------
    // helpers
    // ---------------------------------------------------------------------------------

    private static MqttDriverOptions LoopbackOpts()
        => new() { Host = "127.0.0.1", Port = 1, UseTls = false, ConnectTimeoutSeconds = 1 };

    /// <summary>Options aimed at <see cref="MiniBroker"/>: plaintext v3.1.1, keep-alive parked out of the way.</summary>
    private static MqttDriverOptions BrokerOpts(int port)
        => new()
        {
            Host = "127.0.0.1",
            Port = port,
            UseTls = false,
            ProtocolVersion = MqttProtocolVersion.V311,
            CleanSession = true,
            KeepAliveSeconds = 300,
            ConnectTimeoutSeconds = 5,
            ReconnectMinBackoffSeconds = 1,
            ReconnectMaxBackoffSeconds = 2,
        };

    /// <summary>
    ///     Captures formatted log messages. The supervisor's failed-attempt path is Debug-only and has
    ///     no other externally visible effect — MQTTnet refuses a connect-while-connected before
    ///     opening a socket, so the broker never sees it — which is precisely why the C1 defect was
    ///     silent. The log is the observable.
    /// </summary>
    private sealed class CapturingLogger : ILogger
    {
        private readonly List<string> _messages = [];

        public int CountContaining(string fragment)
        {
            lock (_messages)
            {
                return _messages.Count(m => m.Contains(fragment, StringComparison.OrdinalIgnoreCase));
            }
        }

        public IDisposable? BeginScope<TState>(TState state)
            where TState : notnull
            => null;

        public bool IsEnabled(LogLevel logLevel) => true;

        public void Log<TState>(
            LogLevel logLevel,
            EventId eventId,
            TState state,
            Exception? exception,
            Func<TState, Exception?, string> formatter)
        {
            lock (_messages)
            {
                _messages.Add(formatter(state, exception));
            }
        }
    }

    private static async Task<bool> WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
    {
        var deadline = Stopwatch.StartNew();
        while (deadline.Elapsed < timeout)
        {
            if (condition())
            {
                return true;
            }

            await Task.Delay(25);
        }

        return condition();
    }

    private static MqttClientTlsOptions BuildTls(MqttDriverOptions opts)
        => MqttConnection.BuildClientOptions(opts, clientIdSuffix: null)
            .ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;

    private static readonly DateTimeOffset NotBefore = DateTimeOffset.UtcNow.AddDays(-1);

    /// <summary>A CA certificate, self-signed when <paramref name="issuer"/> is null.</summary>
    private static X509Certificate2 IssueCa(X509Certificate2? issuer, string commonName, int validDays)
    {
        using var key = RSA.Create(2048);
        var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
        request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
        request.CertificateExtensions.Add(
            new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
        request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));

        if (issuer is null)
        {
            return request.CreateSelfSigned(NotBefore, NotBefore.AddDays(validDays));
        }

        // Create() drops the private key; an intermediate needs it back to sign its own children.
        using var issued = request.Create(issuer, NotBefore, NotBefore.AddDays(validDays), NextSerial());
        return issued.CopyWithPrivateKey(key);
    }

    /// <summary>A server (end-entity) certificate signed by <paramref name="issuer"/>.</summary>
    private static X509Certificate2 IssueLeaf(X509Certificate2 issuer, string commonName, int validDays)
    {
        using var key = RSA.Create(2048);
        var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
        request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
        request.CertificateExtensions.Add(
            new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true));
        request.CertificateExtensions.Add(
            new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.1")], false)); // serverAuth
        request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));

        return request.Create(issuer, NotBefore, NotBefore.AddDays(validDays), NextSerial());
    }

    /// <summary>A self-signed leaf that chains to nothing — the "stranger" a pin must reject.</summary>
    private static X509Certificate2 SelfSignedLeaf(string commonName)
    {
        using var key = RSA.Create(2048);
        var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
        return request.CreateSelfSigned(NotBefore, NotBefore.AddDays(20));
    }

    private static byte[] NextSerial() => RandomNumberGenerator.GetBytes(8);

    private static string WritePem(params X509Certificate2[] certificates)
    {
        var path = Path.Combine(Path.GetTempPath(), $"otopcua-mqtt-ca-{Guid.NewGuid():N}.pem");
        File.WriteAllText(path, string.Join(Environment.NewLine, certificates.Select(c => c.ExportCertificatePem())));
        return path;
    }

    /// <summary>
    ///     Mimics what <c>SslStream</c> hands a validation callback: the presented leaf plus a chain
    ///     carrying whatever intermediates the peer supplied. The chain is built (and allowed to
    ///     fail) purely so <c>ChainElements</c> is populated the way the platform would populate it.
    /// </summary>
    private static MqttClientCertificateValidationEventArgs ValidationArgs(
        MqttClientOptions built,
        X509Certificate2 leaf,
        params X509Certificate2[] presentedIntermediates)
    {
        var chain = new X509Chain();
        chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
        chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllFlags;
        foreach (var intermediate in presentedIntermediates)
        {
            chain.ChainPolicy.ExtraStore.Add(intermediate);
        }

        chain.Build(leaf);

        return new MqttClientCertificateValidationEventArgs(
            leaf,
            chain,
            SslPolicyErrors.RemoteCertificateChainErrors,
            built.ChannelOptions);
    }

    /// <summary>
    ///     Accepts the TCP connection and then says nothing — the client sends CONNECT and waits
    ///     forever for a CONNACK that never comes. This is the frozen-peer shape; a closed port is
    ///     not (it refuses instantly and proves nothing about a deadline).
    /// </summary>
    private sealed class BlackholeBroker : IDisposable
    {
        private readonly List<TcpClient> _accepted = [];
        private readonly CancellationTokenSource _cts = new();
        private readonly TcpListener _listener;
        private int _acceptedCount;

        public BlackholeBroker()
        {
            _listener = new TcpListener(IPAddress.Loopback, 0);
            _listener.Start();
            Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
            _ = Task.Run(AcceptLoopAsync);
        }

        public int Port { get; }

        /// <summary>Total TCP connections accepted — how a caller detects a background retry storm.</summary>
        public int AcceptedCount => Volatile.Read(ref _acceptedCount);

        public void Dispose()
        {
            _cts.Cancel();
            _listener.Stop();
            lock (_accepted)
            {
                foreach (var client in _accepted)
                {
                    client.Dispose();
                }

                _accepted.Clear();
            }

            _cts.Dispose();
        }

        private async Task AcceptLoopAsync()
        {
            try
            {
                while (!_cts.IsCancellationRequested)
                {
                    var client = await _listener.AcceptTcpClientAsync(_cts.Token);
                    Interlocked.Increment(ref _acceptedCount);
                    lock (_accepted)
                    {
                        _accepted.Add(client);
                    }
                }
            }
            catch (Exception)
            {
                // Listener stopped / token cancelled — the fixture is going away.
            }
        }
    }
}
