using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server;
using NATS.Server.Auth;
using NATS.Server.Imports;
using NATS.Server.Protocol;
using NATS.Server.Subscriptions;

using NATS.Server.TestUtilities;

namespace NATS.Server.Auth.Tests.Accounts;

/// <summary>
/// Tests for auth callout behavior, account limits (max connections / max subscriptions),
/// user revocation, and cross-account communication scenarios.
/// Reference: Go auth_callout_test.go — TestAuthCallout*, TestAuthCalloutTimeout, etc.
/// Reference: Go accounts_test.go — TestAccountMaxConns, TestAccountMaxSubs,
///            TestUserRevoked*, TestCrossAccountRequestReply.
/// </summary>
public class AuthCalloutTests
{
    private static NatsServer CreateTestServer(NatsOptions? options = null)
    {
        var port = TestPortAllocator.GetFreePort();
        options ??= new NatsOptions();
        options.Port = port;
        return new NatsServer(options, NullLoggerFactory.Instance);
    }

    private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
    {
        var port = TestPortAllocator.GetFreePort();
        options.Port = port;
        var server = new NatsServer(options, NullLoggerFactory.Instance);
        var cts = new CancellationTokenSource();
        _ = server.StartAsync(cts.Token);
        await server.WaitForReadyAsync();
        return (server, port, cts);
    }

    private static bool ExceptionChainContains(Exception ex, string substring)
    {
        Exception? current = ex;
        while (current != null)
        {
            if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase))
                return true;
            current = current.InnerException;
        }
        return false;
    }

    // ── Auth callout handler registration ────────────────────────────────────

    // Go: TestAuthCallout auth_callout_test.go — callout registered in options
    [Fact]
    public void AuthCallout_handler_registered_in_options()
    {
        var client = new StubExternalAuthClient(allow: true, identity: "callout-user");
        var options = new NatsOptions
        {
            ExternalAuth = new ExternalAuthOptions
            {
                Enabled = true,
                Client = client,
                Timeout = TimeSpan.FromSeconds(2),
            },
        };

        var authService = AuthService.Build(options);
        authService.IsAuthRequired.ShouldBeTrue();
    }

    // Go: TestAuthCallout auth_callout_test.go — callout invoked with valid credentials
    [Fact]
    public void AuthCallout_valid_credentials_returns_auth_result()
    {
        var client = new StubExternalAuthClient(allow: true, identity: "callout-user", account: "acct-a");
        var authService = AuthService.Build(new NatsOptions
        {
            ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) },
        });

        var result = authService.Authenticate(new ClientAuthContext
        {
            Opts = new ClientOptions { Username = "user", Password = "pass" },
            Nonce = [],
        });

        result.ShouldNotBeNull();
        result!.Identity.ShouldBe("callout-user");
        result.AccountName.ShouldBe("acct-a");
    }

    // Go: TestAuthCallout auth_callout_test.go — callout with invalid credentials fails
    [Fact]
    public void AuthCallout_invalid_credentials_returns_null()
    {
        var client = new StubExternalAuthClient(allow: false);
        var authService = AuthService.Build(new NatsOptions
        {
            ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) },
        });

        var result = authService.Authenticate(new ClientAuthContext
        {
            Opts = new ClientOptions { Username = "bad-user", Password = "bad-pass" },
            Nonce = [],
        });

        result.ShouldBeNull();
    }

    // Go: TestAuthCalloutTimeout auth_callout_test.go — callout timeout returns null
    [Fact]
    public void AuthCallout_timeout_returns_null()
    {
        var client = new DelayedExternalAuthClient(delay: TimeSpan.FromSeconds(5));
        var authService = AuthService.Build(new NatsOptions
        {
            ExternalAuth = new ExternalAuthOptions
            {
                Enabled = true,
                Client = client,
                Timeout = TimeSpan.FromMilliseconds(50),
            },
        });

        var result = authService.Authenticate(new ClientAuthContext
        {
            Opts = new ClientOptions { Username = "user", Password = "pass" },
            Nonce = [],
        });

        result.ShouldBeNull();
    }

    // Go: TestAuthCallout auth_callout_test.go — callout response assigns account
    [Fact]
    public void AuthCallout_response_assigns_account_name()
    {
        var client = new StubExternalAuthClient(allow: true, identity: "alice", account: "tenant-1");
        var authService = AuthService.Build(new NatsOptions
        {
            ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) },
        });

        var result = authService.Authenticate(new ClientAuthContext
        {
            Opts = new ClientOptions { Username = "alice", Password = "x" },
            Nonce = [],
        });

        result.ShouldNotBeNull();
        result!.AccountName.ShouldBe("tenant-1");
    }

    // Go: TestAuthCallout auth_callout_test.go — callout with no account in response
    [Fact]
    public void AuthCallout_no_account_in_response_returns_null_account_name()
    {
        var client = new StubExternalAuthClient(allow: true, identity: "anonymous-user", account: null);
        var authService = AuthService.Build(new NatsOptions
        {
            ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) },
        });

        var result = authService.Authenticate(new ClientAuthContext
        {
            Opts = new ClientOptions { Username = "anon", Password = "x" },
            Nonce = [],
        });

        result.ShouldNotBeNull();
        result!.AccountName.ShouldBeNull();
    }

    // Go: TestAuthCallout auth_callout_test.go — callout invoked (receives request data)
    [Fact]
    public void AuthCallout_receives_username_and_password()
    {
        var captureClient = new CapturingExternalAuthClient(allow: true, identity: "u");
        var authService = AuthService.Build(new NatsOptions
        {
            ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = captureClient, Timeout = TimeSpan.FromSeconds(2) },
        });

        authService.Authenticate(new ClientAuthContext
        {
            Opts = new ClientOptions { Username = "myuser", Password = "mypass" },
            Nonce = [],
        });

        captureClient.LastRequest.ShouldNotBeNull();
        captureClient.LastRequest!.Username.ShouldBe("myuser");
        captureClient.LastRequest.Password.ShouldBe("mypass");
    }

    // Go: TestAuthCallout auth_callout_test.go — callout invoked with token
    [Fact]
    public void AuthCallout_receives_token()
    {
        var captureClient = new CapturingExternalAuthClient(allow: true, identity: "u");
        var authService = AuthService.Build(new NatsOptions
        {
            ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = captureClient, Timeout = TimeSpan.FromSeconds(2) },
        });

        authService.Authenticate(new ClientAuthContext
        {
            Opts = new ClientOptions { Token = "my-bearer-token" },
            Nonce = [],
        });

        captureClient.LastRequest.ShouldNotBeNull();
        captureClient.LastRequest!.Token.ShouldBe("my-bearer-token");
    }

    // Go: TestAuthCallout auth_callout_test.go — callout invoked for each connection
    [Fact]
    public void AuthCallout_invoked_for_each_authentication_attempt()
    {
        var client = new CountingExternalAuthClient(allow: true, identity: "u");
        var authService = AuthService.Build(new NatsOptions
        {
            ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) },
        });

        for (int i = 0; i < 5; i++)
        {
            authService.Authenticate(new ClientAuthContext
            {
                Opts = new ClientOptions { Username = $"user{i}", Password = "p" },
                Nonce = [],
            });
        }

        client.CallCount.ShouldBe(5);
    }

    // ── Account limits: max connections ──────────────────────────────────────

    // Go: TestAccountMaxConns accounts_test.go — max connections limit enforced
    [Fact]
    public void Account_max_connections_enforced()
    {
        using var server = CreateTestServer();
        var acc = server.GetOrCreateAccount("limited");
        acc.MaxConnections = 2;

        acc.AddClient(1).ShouldBeTrue();
        acc.AddClient(2).ShouldBeTrue();
        acc.AddClient(3).ShouldBeFalse(); // limit reached
    }

    // Go: TestAccountMaxConns accounts_test.go — zero max connections means unlimited
    [Fact]
    public void Account_zero_max_connections_means_unlimited()
    {
        using var server = CreateTestServer();
        var acc = server.GetOrCreateAccount("unlimited");
        acc.MaxConnections = 0; // unlimited

        for (ulong i = 1; i <= 100; i++)
            acc.AddClient(i).ShouldBeTrue();

        acc.ClientCount.ShouldBe(100);
    }

    // Go: TestAccountMaxConns accounts_test.go — connection count tracked
    [Fact]
    public void Account_connection_count_tracking()
    {
        using var server = CreateTestServer();
        var acc = server.GetOrCreateAccount("tracked");

        acc.AddClient(1);
        acc.AddClient(2);
        acc.AddClient(3);

        acc.ClientCount.ShouldBe(3);
    }

    // Go: TestAccountMaxConns accounts_test.go — limits reset after disconnect
    [Fact]
    public void Account_connection_limit_resets_after_disconnect()
    {
        using var server = CreateTestServer();
        var acc = server.GetOrCreateAccount("resetable");
        acc.MaxConnections = 2;

        acc.AddClient(1).ShouldBeTrue();
        acc.AddClient(2).ShouldBeTrue();
        acc.AddClient(3).ShouldBeFalse(); // full

        acc.RemoveClient(1); // disconnect one

        acc.AddClient(3).ShouldBeTrue(); // now room for another
    }

    // Go: TestAccountMaxConns accounts_test.go — different accounts have independent limits
    [Fact]
    public void Account_limits_are_per_account_independent()
    {
        using var server = CreateTestServer();
        var accA = server.GetOrCreateAccount("acct-a");
        var accB = server.GetOrCreateAccount("acct-b");

        accA.MaxConnections = 2;
        accB.MaxConnections = 5;

        accA.AddClient(1).ShouldBeTrue();
        accA.AddClient(2).ShouldBeTrue();
        accA.AddClient(3).ShouldBeFalse(); // A is full

        // B is independent — should still allow
        accB.AddClient(10).ShouldBeTrue();
        accB.AddClient(11).ShouldBeTrue();
        accB.AddClient(12).ShouldBeTrue();
    }

    // Go: TestAccountMaxConns accounts_test.go — config-driven max connections
    [Fact]
    public void Account_from_config_applies_max_connections()
    {
        using var server = CreateTestServer(new NatsOptions
        {
            Accounts = new Dictionary<string, AccountConfig>
            {
                ["limited"] = new AccountConfig { MaxConnections = 3 },
            },
        });

        var acc = server.GetOrCreateAccount("limited");
        acc.MaxConnections.ShouldBe(3);

        acc.AddClient(1).ShouldBeTrue();
        acc.AddClient(2).ShouldBeTrue();
        acc.AddClient(3).ShouldBeTrue();
        acc.AddClient(4).ShouldBeFalse();
    }

    // ── Account limits: max subscriptions ────────────────────────────────────

    // Go: TestAccountMaxSubs accounts_test.go — max subscriptions enforced
    [Fact]
    public void Account_max_subscriptions_enforced()
    {
        using var server = CreateTestServer();
        var acc = server.GetOrCreateAccount("sub-limited");
        acc.MaxSubscriptions = 2;

        acc.IncrementSubscriptions().ShouldBeTrue();
        acc.IncrementSubscriptions().ShouldBeTrue();
        acc.IncrementSubscriptions().ShouldBeFalse(); // limit reached
    }

    // Go: TestAccountMaxSubs accounts_test.go — zero max subscriptions means unlimited
    [Fact]
    public void Account_zero_max_subscriptions_means_unlimited()
    {
        using var server = CreateTestServer();
        var acc = server.GetOrCreateAccount("unlimited-subs");
        acc.MaxSubscriptions = 0;

        for (int i = 0; i < 100; i++)
            acc.IncrementSubscriptions().ShouldBeTrue();

        acc.SubscriptionCount.ShouldBe(100);
    }

    // Go: TestAccountMaxSubs accounts_test.go — subscription count tracked
    [Fact]
    public void Account_subscription_count_tracking()
    {
        using var server = CreateTestServer();
        var acc = server.GetOrCreateAccount("sub-tracked");

        acc.IncrementSubscriptions();
        acc.IncrementSubscriptions();
        acc.IncrementSubscriptions();

        acc.SubscriptionCount.ShouldBe(3);
    }

    // Go: TestAccountMaxSubs accounts_test.go — decrement frees capacity
    [Fact]
    public void Account_subscription_decrement_frees_capacity()
    {
        using var server = CreateTestServer();
        var acc = server.GetOrCreateAccount("sub-freeable");
        acc.MaxSubscriptions = 2;

        acc.IncrementSubscriptions().ShouldBeTrue();
        acc.IncrementSubscriptions().ShouldBeTrue();
        acc.IncrementSubscriptions().ShouldBeFalse(); // full

        acc.DecrementSubscriptions(); // free one

        acc.IncrementSubscriptions().ShouldBeTrue(); // now fits
    }

    // Go: TestAccountMaxSubs accounts_test.go — config-driven max subscriptions
    [Fact]
    public void Account_from_config_applies_max_subscriptions()
    {
        using var server = CreateTestServer(new NatsOptions
        {
            Accounts = new Dictionary<string, AccountConfig>
            {
                ["sub-limited"] = new AccountConfig { MaxSubscriptions = 5 },
            },
        });

        var acc = server.GetOrCreateAccount("sub-limited");
        acc.MaxSubscriptions.ShouldBe(5);
    }

    // Go: TestAccountMaxSubs accounts_test.go — different accounts have independent subscription limits
    [Fact]
    public void Account_subscription_limits_are_independent()
    {
        using var server = CreateTestServer();
        var accA = server.GetOrCreateAccount("sub-a");
        var accB = server.GetOrCreateAccount("sub-b");

        accA.MaxSubscriptions = 1;
        accB.MaxSubscriptions = 3;

        accA.IncrementSubscriptions().ShouldBeTrue();
        accA.IncrementSubscriptions().ShouldBeFalse(); // A full

        accB.IncrementSubscriptions().ShouldBeTrue();
        accB.IncrementSubscriptions().ShouldBeTrue();
        accB.IncrementSubscriptions().ShouldBeTrue(); // B has capacity
    }

    // ── User revocation ───────────────────────────────────────────────────────

    // Go: TestUserRevoked accounts_test.go — revoked user rejected
    [Fact]
    public void Revoked_user_is_rejected()
    {
        using var server = CreateTestServer();
        var acc = server.GetOrCreateAccount("revocation-test");

        acc.RevokeUser("UNKEY123", issuedAt: 1000);

        acc.IsUserRevoked("UNKEY123", issuedAt: 999).ShouldBeTrue();
        acc.IsUserRevoked("UNKEY123", issuedAt: 1000).ShouldBeTrue();
    }

    // Go: TestUserRevoked accounts_test.go — not-yet-revoked user is allowed
    [Fact]
    public void User_issued_after_revocation_time_is_allowed()
    {
        using var server = CreateTestServer();
        var acc = server.GetOrCreateAccount("revocation-test");

        acc.RevokeUser("UNKEY456", issuedAt: 1000);

        // Issued after the revocation timestamp — should be allowed
        acc.IsUserRevoked("UNKEY456", issuedAt: 1001).ShouldBeFalse();
    }

    // Go: TestUserRevoked accounts_test.go — non-existent user is not revoked
    [Fact]
    public void Non_revoked_user_is_allowed()
    {
        using var server = CreateTestServer();
        var acc = server.GetOrCreateAccount("revocation-test");

        acc.IsUserRevoked("UNKEY999", issuedAt: 500).ShouldBeFalse();
    }

    // Go: TestUserRevoked accounts_test.go — wildcard revocation affects all users
    [Fact]
    public void Wildcard_revocation_rejects_any_user()
    {
        using var server = CreateTestServer();
        var acc = server.GetOrCreateAccount("revocation-test");

        // Revoke ALL users issued at or before timestamp 2000
        acc.RevokeUser("*", issuedAt: 2000);

        acc.IsUserRevoked("UNKEY_A", issuedAt: 1000).ShouldBeTrue();
        acc.IsUserRevoked("UNKEY_B", issuedAt: 2000).ShouldBeTrue();
        acc.IsUserRevoked("UNKEY_C", issuedAt: 2001).ShouldBeFalse();
    }

    // Go: TestUserRevoked accounts_test.go — revocation of non-existent user is no-op
    [Fact]
    public void Revoking_non_existent_user_is_no_op()
    {
        using var server = CreateTestServer();
        var acc = server.GetOrCreateAccount("revocation-test");

        // Should not throw
        var ex = Record.Exception(() => acc.RevokeUser("NONEXISTENT_KEY", issuedAt: 500));
        ex.ShouldBeNull();
    }

    // Go: TestUserRevoked accounts_test.go — re-revoke at later time updates revocation
    [Fact]
    public void Re_revoking_user_with_later_timestamp_updates_revocation()
    {
        using var server = CreateTestServer();
        var acc = server.GetOrCreateAccount("revocation-test");

        acc.RevokeUser("UNKEY_RE", issuedAt: 1000);
        // User issued at 1001 is currently allowed
        acc.IsUserRevoked("UNKEY_RE", issuedAt: 1001).ShouldBeFalse();

        // Re-revoke at a later timestamp
        acc.RevokeUser("UNKEY_RE", issuedAt: 2000);
        // Now user issued at 1001 should be rejected
        acc.IsUserRevoked("UNKEY_RE", issuedAt: 1001).ShouldBeTrue();
        // User issued at 2001 still allowed
        acc.IsUserRevoked("UNKEY_RE", issuedAt: 2001).ShouldBeFalse();
    }

    // ── Cross-account communication ───────────────────────────────────────────

    // Go: TestCrossAccountRequestReply accounts_test.go — service export visibility
    [Fact]
    public void Service_export_is_visible_in_exporter_account()
    {
        using var server = CreateTestServer();
        var exporter = server.GetOrCreateAccount("exporter");

        exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);

        exporter.Exports.Services.ShouldContainKey("api.>");
        exporter.Exports.Services["api.>"].Account.ShouldBeSameAs(exporter);
    }

    // Go: TestCrossAccountRequestReply accounts_test.go — service import routing
    [Fact]
    public void Service_import_routes_to_exporter_sublist()
    {
        using var server = CreateTestServer();
        var exporter = server.GetOrCreateAccount("exporter");
        var importer = server.GetOrCreateAccount("importer");

        exporter.AddServiceExport("svc.calc", ServiceResponseType.Singleton, null);
        importer.AddServiceImport(exporter, "requests.calc", "svc.calc");

        var received = new List<string>();
        var mockClient = new TestNatsClient(1, exporter);
        mockClient.OnMessage = (subject, _, _, _, _) => received.Add(subject);

        exporter.SubList.Insert(new Subscription { Subject = "svc.calc", Sid = "s1", Client = mockClient });

        var si = importer.Imports.Services["requests.calc"][0];
        server.ProcessServiceImport(si, "requests.calc", null, default, default);

        received.Count.ShouldBe(1);
        received[0].ShouldBe("svc.calc");
    }

    // Go: TestCrossAccountRequestReply accounts_test.go — response routed back to importer
    [Fact]
    public void Service_import_response_preserves_reply_to_inbox()
    {
        using var server = CreateTestServer();
        var exporter = server.GetOrCreateAccount("exporter");
        var importer = server.GetOrCreateAccount("importer");

        exporter.AddServiceExport("api.query", ServiceResponseType.Singleton, null);
        importer.AddServiceImport(exporter, "q.query", "api.query");

        string? capturedReply = null;
        var mockClient = new TestNatsClient(1, exporter);
        mockClient.OnMessage = (_, _, replyTo, _, _) => capturedReply = replyTo;

        exporter.SubList.Insert(new Subscription { Subject = "api.query", Sid = "s1", Client = mockClient });

        var si = importer.Imports.Services["q.query"][0];
        server.ProcessServiceImport(si, "q.query", "_INBOX.reply.001", default, default);

        capturedReply.ShouldBe("_INBOX.reply.001");
    }

    // Go: TestCrossAccountRequestReply accounts_test.go — wildcard import/export matching
    [Fact]
    public void Wildcard_service_import_maps_token_suffix()
    {
        using var server = CreateTestServer();
        var exporter = server.GetOrCreateAccount("exporter");
        var importer = server.GetOrCreateAccount("importer");

        exporter.AddServiceExport("backend.>", ServiceResponseType.Singleton, null);
        importer.AddServiceImport(exporter, "public.>", "backend.>");

        var received = new List<string>();
        var mockClient = new TestNatsClient(1, exporter);
        mockClient.OnMessage = (subject, _, _, _, _) => received.Add(subject);

        exporter.SubList.Insert(new Subscription { Subject = "backend.echo", Sid = "s1", Client = mockClient });

        var si = importer.Imports.Services["public.>"][0];
        server.ProcessServiceImport(si, "public.echo", null, default, default);

        received.Count.ShouldBe(1);
        received[0].ShouldBe("backend.echo");
    }

    // Go: TestCrossAccountRequestReply accounts_test.go — account subject namespaces independent
    [Fact]
    public void Account_specific_subject_namespaces_are_independent()
    {
        using var server = CreateTestServer();
        var accA = server.GetOrCreateAccount("ns-a");
        var accB = server.GetOrCreateAccount("ns-b");

        var receivedA = new List<string>();
        var receivedB = new List<string>();

        var clientA = new TestNatsClient(1, accA);
        clientA.OnMessage = (subject, _, _, _, _) => receivedA.Add(subject);
        var clientB = new TestNatsClient(2, accB);
        clientB.OnMessage = (subject, _, _, _, _) => receivedB.Add(subject);

        accA.SubList.Insert(new Subscription { Subject = "shared.topic", Sid = "a1", Client = clientA });
        accB.SubList.Insert(new Subscription { Subject = "shared.topic", Sid = "b1", Client = clientB });

        // Publish only to A's namespace
        var resultA = accA.SubList.Match("shared.topic");
        foreach (var sub in resultA.PlainSubs)
            sub.Client?.SendMessage("shared.topic", sub.Sid, null, default, default);

        receivedA.Count.ShouldBe(1);
        receivedB.Count.ShouldBe(0); // B's subscription not in A's sublist
    }

    // Go: accounts_test.go — proxy authenticator routes to correct account
    [Fact]
    public void ProxyAuthenticator_routes_to_configured_account()
    {
        var authService = AuthService.Build(new NatsOptions
        {
            ProxyAuth = new ProxyAuthOptions
            {
                Enabled = true,
                UsernamePrefix = "proxy:",
                Account = "proxy-account",
            },
        });

        var result = authService.Authenticate(new ClientAuthContext
        {
            Opts = new ClientOptions { Username = "proxy:my-identity" },
            Nonce = [],
        });

        result.ShouldNotBeNull();
        result!.Identity.ShouldBe("my-identity");
        result.AccountName.ShouldBe("proxy-account");
    }

    // Go: accounts_test.go — proxy authenticator rejects non-matching prefix
    [Fact]
    public void ProxyAuthenticator_rejects_non_matching_prefix()
    {
        var authService = AuthService.Build(new NatsOptions
        {
            ProxyAuth = new ProxyAuthOptions
            {
                Enabled = true,
                UsernamePrefix = "proxy:",
                Account = "proxy-account",
            },
        });

        var result = authService.Authenticate(new ClientAuthContext
        {
            Opts = new ClientOptions { Username = "direct-user", Password = "x" },
            Nonce = [],
        });

        result.ShouldBeNull();
    }

    // Go: auth_callout_test.go — integration: callout allowed connection succeeds
    [Fact]
    public async Task AuthCallout_allowed_connection_connects_successfully()
    {
        var calloutClient = new StubExternalAuthClient(allow: true, identity: "user1");
        var (server, port, cts) = await StartServerAsync(new NatsOptions
        {
            ExternalAuth = new ExternalAuthOptions
            {
                Enabled = true,
                Client = calloutClient,
                Timeout = TimeSpan.FromSeconds(2),
            },
        });

        try
        {
            await using var nats = new NatsConnection(new NatsOpts
            {
                Url = $"nats://user1:anypass@127.0.0.1:{port}",
            });

            await nats.ConnectAsync();
            await nats.PingAsync();
        }
        finally
        {
            await cts.CancelAsync();
            server.Dispose();
        }
    }

    // Go: auth_callout_test.go — integration: callout denied connection fails
    [Fact]
    public async Task AuthCallout_denied_connection_is_rejected()
    {
        var calloutClient = new StubExternalAuthClient(allow: false);
        var (server, port, cts) = await StartServerAsync(new NatsOptions
        {
            ExternalAuth = new ExternalAuthOptions
            {
                Enabled = true,
                Client = calloutClient,
                Timeout = TimeSpan.FromSeconds(2),
            },
        });

        try
        {
            await using var nats = new NatsConnection(new NatsOpts
            {
                Url = $"nats://bad-user:badpass@127.0.0.1:{port}",
                MaxReconnectRetry = 0,
            });

            var ex = await Should.ThrowAsync<NatsException>(async () =>
            {
                await nats.ConnectAsync();
                await nats.PingAsync();
            });

            ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue(
                $"Expected 'Authorization Violation' in exception chain, but got: {ex}");
        }
        finally
        {
            await cts.CancelAsync();
            server.Dispose();
        }
    }

    // ── Test doubles ─────────────────────────────────────────────────────────

    private sealed class StubExternalAuthClient(bool allow, string? identity = null, string? account = null)
        : IExternalAuthClient
    {
        public Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct) =>
            Task.FromResult(new ExternalAuthDecision(allow, identity, account));
    }

    private sealed class DelayedExternalAuthClient(TimeSpan delay) : IExternalAuthClient
    {
        public async Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
        {
            var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
            await using var reg = ct.Register(() => tcs.TrySetCanceled(ct));
            using var timer = new Timer(_ => tcs.TrySetResult(true), null, delay, Timeout.InfiniteTimeSpan);
            await tcs.Task;
            return new ExternalAuthDecision(true, "delayed");
        }
    }

    private sealed class CapturingExternalAuthClient(bool allow, string identity) : IExternalAuthClient
    {
        public ExternalAuthRequest? LastRequest { get; private set; }

        public Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
        {
            LastRequest = request;
            return Task.FromResult(new ExternalAuthDecision(allow, identity));
        }
    }

    private sealed class CountingExternalAuthClient(bool allow, string identity) : IExternalAuthClient
    {
        private int _callCount;
        public int CallCount => _callCount;

        public Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
        {
            Interlocked.Increment(ref _callCount);
            return Task.FromResult(new ExternalAuthDecision(allow, identity));
        }
    }

    private sealed class TestNatsClient(ulong id, Account account) : INatsClient
    {
        public ulong Id => id;
        public ClientKind Kind => ClientKind.Client;
        public Account? Account => account;
        public ClientOptions? ClientOpts => null;
        public ClientPermissions? Permissions => null;

        public Action<string, string, string?, ReadOnlyMemory<byte>, ReadOnlyMemory<byte>>? OnMessage { get; set; }

        public void SendMessage(string subject, string sid, string? replyTo,
            ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
        {
            OnMessage?.Invoke(subject, sid, replyTo, headers, payload);
        }

        public bool QueueOutbound(ReadOnlyMemory<byte> data) => true;
        public void RemoveSubscription(string sid) { }
    }
}
