using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Configuration;
using NATS.Server.LeafNodes;
using NATS.Server.Subscriptions;
using NATS.Server.TestUtilities;

namespace NATS.Server.LeafNodes.Tests.LeafNodes;

/// <summary>
/// Tests for leaf node subject filtering via DenyExports/DenyImports (deny-lists) and
/// ExportSubjects/ImportSubjects (allow-lists). When an allow-list is non-empty, only
/// subjects matching at least one allow pattern are permitted; deny takes precedence.
/// Go reference: leafnode.go:470-507 (newLeafNodeCfg), opts.go:230-231,
/// auth.go:127 (SubjectPermission with Allow + Deny).
/// </summary>
public class LeafSubjectFilterTests
{
    // ── LeafHubSpokeMapper.IsSubjectAllowed Unit Tests ────────────────

    // Go: TestLeafNodePermissions server/leafnode_test.go:1267
    [Fact]
    public void Literal_deny_export_blocks_outbound_subject()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string>(),
            denyExports: ["secret.data"],
            denyImports: []);

        mapper.IsSubjectAllowed("secret.data", LeafMapDirection.Outbound).ShouldBeFalse();
        mapper.IsSubjectAllowed("public.data", LeafMapDirection.Outbound).ShouldBeTrue();
    }

    // Go: TestLeafNodePermissions server/leafnode_test.go:1267
    [Fact]
    public void Literal_deny_import_blocks_inbound_subject()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string>(),
            denyExports: [],
            denyImports: ["internal.status"]);

        mapper.IsSubjectAllowed("internal.status", LeafMapDirection.Inbound).ShouldBeFalse();
        mapper.IsSubjectAllowed("external.status", LeafMapDirection.Inbound).ShouldBeTrue();
    }

    // Go: TestLeafNodePermissions server/leafnode_test.go:1267
    [Fact]
    public void Wildcard_deny_export_blocks_matching_subjects()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string>(),
            denyExports: ["admin.*"],
            denyImports: []);

        mapper.IsSubjectAllowed("admin.users", LeafMapDirection.Outbound).ShouldBeFalse();
        mapper.IsSubjectAllowed("admin.config", LeafMapDirection.Outbound).ShouldBeFalse();
        mapper.IsSubjectAllowed("admin.deep.nested", LeafMapDirection.Outbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("public.data", LeafMapDirection.Outbound).ShouldBeTrue();
    }

    // Go: TestLeafNodePermissions server/leafnode_test.go:1267
    [Fact]
    public void Fwc_deny_import_blocks_all_matching_subjects()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string>(),
            denyExports: [],
            denyImports: ["_SYS.>"]);

        mapper.IsSubjectAllowed("_SYS.heartbeat", LeafMapDirection.Inbound).ShouldBeFalse();
        mapper.IsSubjectAllowed("_SYS.a.b.c", LeafMapDirection.Inbound).ShouldBeFalse();
        mapper.IsSubjectAllowed("user.data", LeafMapDirection.Inbound).ShouldBeTrue();
    }

    // Go: TestLeafNodePermissions server/leafnode_test.go:1267
    [Fact]
    public void Bidirectional_filtering_applies_independently()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string>(),
            denyExports: ["export.denied"],
            denyImports: ["import.denied"]);

        // Export deny does not affect inbound direction
        mapper.IsSubjectAllowed("export.denied", LeafMapDirection.Inbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("export.denied", LeafMapDirection.Outbound).ShouldBeFalse();

        // Import deny does not affect outbound direction
        mapper.IsSubjectAllowed("import.denied", LeafMapDirection.Outbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("import.denied", LeafMapDirection.Inbound).ShouldBeFalse();
    }

    // Go: TestLeafNodePermissions server/leafnode_test.go:1267
    [Fact]
    public void Multiple_deny_patterns_all_evaluated()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string>(),
            denyExports: ["admin.*", "secret.>", "internal.config"],
            denyImports: []);

        mapper.IsSubjectAllowed("admin.users", LeafMapDirection.Outbound).ShouldBeFalse();
        mapper.IsSubjectAllowed("secret.key.value", LeafMapDirection.Outbound).ShouldBeFalse();
        mapper.IsSubjectAllowed("internal.config", LeafMapDirection.Outbound).ShouldBeFalse();
        mapper.IsSubjectAllowed("public.data", LeafMapDirection.Outbound).ShouldBeTrue();
    }

    // Go: TestLeafNodePermissions server/leafnode_test.go:1267
    [Fact]
    public void Empty_deny_lists_allow_everything()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string>(),
            denyExports: [],
            denyImports: []);

        mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Outbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Inbound).ShouldBeTrue();
    }

    // Go: TestLeafNodePermissions server/leafnode_test.go:1267
    [Fact]
    public void Account_mapping_still_works_with_subject_filter()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string> { ["HUB_ACCT"] = "SPOKE_ACCT" },
            denyExports: ["denied.>"],
            denyImports: []);

        var outbound = mapper.Map("HUB_ACCT", "foo.bar", LeafMapDirection.Outbound);
        outbound.Account.ShouldBe("SPOKE_ACCT");
        outbound.Subject.ShouldBe("foo.bar");

        var inbound = mapper.Map("SPOKE_ACCT", "foo.bar", LeafMapDirection.Inbound);
        inbound.Account.ShouldBe("HUB_ACCT");
        inbound.Subject.ShouldBe("foo.bar");

        mapper.IsSubjectAllowed("denied.test", LeafMapDirection.Outbound).ShouldBeFalse();
    }

    // Go: TestLeafNodePermissions server/leafnode_test.go:1267
    [Fact]
    public void Default_constructor_allows_everything()
    {
        var mapper = new LeafHubSpokeMapper(new Dictionary<string, string>());
        mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Outbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Inbound).ShouldBeTrue();
    }

    // ── Integration: DenyExports blocks hub→leaf message forwarding ────

    // Go: TestLeafNodePermissions server/leafnode_test.go:1267
    [Fact]
    public async Task DenyExports_blocks_message_forwarding_hub_to_leaf()
    {
        // Start a hub with DenyExports configured
        var hubOptions = new NatsOptions
        {
            Host = "127.0.0.1",
            Port = 0,
            LeafNode = new LeafNodeOptions
            {
                Host = "127.0.0.1",
                Port = 0,
                DenyExports = ["secret.>"],
            },
        };

        var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
        var hubCts = new CancellationTokenSource();
        _ = hub.StartAsync(hubCts.Token);
        await hub.WaitForReadyAsync();

        try
        {
            var spokeOptions = new NatsOptions
            {
                Host = "127.0.0.1",
                Port = 0,
                LeafNode = new LeafNodeOptions
                {
                    Host = "127.0.0.1",
                    Port = 0,
                    Remotes = [hub.LeafListen!],
                },
            };

            var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
            var spokeCts = new CancellationTokenSource();
            _ = spoke.StartAsync(spokeCts.Token);
            await spoke.WaitForReadyAsync();

            try
            {
                // Wait for leaf connection
                await PollHelper.WaitUntilAsync(() => !((hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0)), timeoutMs: 5000);

                await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
                await leafConn.ConnectAsync();
                await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
                await hubConn.ConnectAsync();

                // Subscribe on spoke for allowed and denied subjects
                await using var allowedSub = await leafConn.SubscribeCoreAsync<string>("public.data");
                await using var deniedSub = await leafConn.SubscribeCoreAsync<string>("secret.data");
                await leafConn.PingAsync();

                // Wait for interest propagation
                await PollHelper.YieldForAsync(500);

                // Publish from hub
                await hubConn.PublishAsync("public.data", "allowed-msg");
                await hubConn.PublishAsync("secret.data", "denied-msg");

                // The allowed message should arrive
                using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
                (await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("allowed-msg");

                // The denied message should NOT arrive
                using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
                await Should.ThrowAsync<OperationCanceledException>(async () =>
                    await deniedSub.Msgs.ReadAsync(leakCts.Token));
            }
            finally
            {
                await spokeCts.CancelAsync();
                spoke.Dispose();
                spokeCts.Dispose();
            }
        }
        finally
        {
            await hubCts.CancelAsync();
            hub.Dispose();
            hubCts.Dispose();
        }
    }

    // Go: TestLeafNodePermissions server/leafnode_test.go:1267
    [Fact]
    public async Task DenyImports_blocks_message_forwarding_leaf_to_hub()
    {
        // Start hub with DenyImports — leaf→hub messages for denied subjects are dropped
        var hubOptions = new NatsOptions
        {
            Host = "127.0.0.1",
            Port = 0,
            LeafNode = new LeafNodeOptions
            {
                Host = "127.0.0.1",
                Port = 0,
                DenyImports = ["private.>"],
            },
        };

        var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
        var hubCts = new CancellationTokenSource();
        _ = hub.StartAsync(hubCts.Token);
        await hub.WaitForReadyAsync();

        try
        {
            var spokeOptions = new NatsOptions
            {
                Host = "127.0.0.1",
                Port = 0,
                LeafNode = new LeafNodeOptions
                {
                    Host = "127.0.0.1",
                    Port = 0,
                    Remotes = [hub.LeafListen!],
                },
            };

            var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
            var spokeCts = new CancellationTokenSource();
            _ = spoke.StartAsync(spokeCts.Token);
            await spoke.WaitForReadyAsync();

            try
            {
                // Wait for leaf connection
                await PollHelper.WaitUntilAsync(() => !((hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0)), timeoutMs: 5000);

                await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
                await hubConn.ConnectAsync();
                await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
                await leafConn.ConnectAsync();

                // Subscribe on hub for both allowed and denied subjects
                await using var allowedSub = await hubConn.SubscribeCoreAsync<string>("public.data");
                await using var deniedSub = await hubConn.SubscribeCoreAsync<string>("private.data");
                await hubConn.PingAsync();

                // Wait for interest propagation
                await PollHelper.YieldForAsync(500);

                // Publish from spoke (leaf)
                await leafConn.PublishAsync("public.data", "allowed-msg");
                await leafConn.PublishAsync("private.data", "denied-msg");

                // The allowed message should arrive on hub
                using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
                (await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("allowed-msg");

                // The denied message should NOT arrive
                using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
                await Should.ThrowAsync<OperationCanceledException>(async () =>
                    await deniedSub.Msgs.ReadAsync(leakCts.Token));
            }
            finally
            {
                await spokeCts.CancelAsync();
                spoke.Dispose();
                spokeCts.Dispose();
            }
        }
        finally
        {
            await hubCts.CancelAsync();
            hub.Dispose();
            hubCts.Dispose();
        }
    }

    // Go: TestLeafNodePermissions server/leafnode_test.go:1267
    [Fact]
    public async Task DenyExports_with_wildcard_blocks_pattern_matching_subjects()
    {
        var hubOptions = new NatsOptions
        {
            Host = "127.0.0.1",
            Port = 0,
            LeafNode = new LeafNodeOptions
            {
                Host = "127.0.0.1",
                Port = 0,
                DenyExports = ["admin.*"],
            },
        };

        var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
        var hubCts = new CancellationTokenSource();
        _ = hub.StartAsync(hubCts.Token);
        await hub.WaitForReadyAsync();

        try
        {
            var spokeOptions = new NatsOptions
            {
                Host = "127.0.0.1",
                Port = 0,
                LeafNode = new LeafNodeOptions
                {
                    Host = "127.0.0.1",
                    Port = 0,
                    Remotes = [hub.LeafListen!],
                },
            };

            var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
            var spokeCts = new CancellationTokenSource();
            _ = spoke.StartAsync(spokeCts.Token);
            await spoke.WaitForReadyAsync();

            try
            {
                await PollHelper.WaitUntilAsync(() => !((hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0)), timeoutMs: 5000);

                await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
                await leafConn.ConnectAsync();
                await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
                await hubConn.ConnectAsync();

                // admin.users should be blocked; admin.deep.nested should pass (* doesn't match multi-token)
                await using var blockedSub = await leafConn.SubscribeCoreAsync<string>("admin.users");
                await using var allowedSub = await leafConn.SubscribeCoreAsync<string>("admin.deep.nested");
                await leafConn.PingAsync();
                await PollHelper.YieldForAsync(500);

                await hubConn.PublishAsync("admin.users", "blocked");
                await hubConn.PublishAsync("admin.deep.nested", "allowed");

                // The multi-token subject passes because * matches only single token
                using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
                (await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("allowed");

                // The single-token subject is blocked
                using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
                await Should.ThrowAsync<OperationCanceledException>(async () =>
                    await blockedSub.Msgs.ReadAsync(leakCts.Token));
            }
            finally
            {
                await spokeCts.CancelAsync();
                spoke.Dispose();
                spokeCts.Dispose();
            }
        }
        finally
        {
            await hubCts.CancelAsync();
            hub.Dispose();
            hubCts.Dispose();
        }
    }

    // ── Wire-level: DenyExports blocks LS+ propagation ──────────────

    // Go: TestLeafNodePermissions server/leafnode_test.go:1267
    [Fact]
    public async Task DenyExports_blocks_subscription_propagation()
    {
        using var listener = new TcpListener(IPAddress.Loopback, 0);
        listener.Start();
        var port = ((IPEndPoint)listener.LocalEndpoint).Port;

        var options = new LeafNodeOptions
        {
            Host = "127.0.0.1",
            Port = 0,
            DenyExports = ["secret.>"],
        };

        var manager = new LeafNodeManager(
            options,
            new ServerStats(),
            "HUB1",
            _ => { },
            _ => { },
            NullLogger<LeafNodeManager>.Instance);

        await manager.StartAsync(CancellationToken.None);
        try
        {
            using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            await remoteSocket.ConnectAsync(IPAddress.Loopback, options.Port);

            // Exchange handshakes — inbound connections send LEAF first, then read response
            using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
            await WriteLineAsync(remoteSocket, "LEAF SPOKE1", cts.Token);
            var line = await ReadLineAsync(remoteSocket, cts.Token);
            line.ShouldStartWith("LEAF ");

            await PollHelper.YieldForAsync(200);

            // Propagate allowed subscription
            manager.PropagateLocalSubscription("$G", "public.data", null);
            await PollHelper.YieldForAsync(100);
            var lsLine = await ReadLineAsync(remoteSocket, cts.Token);
            lsLine.ShouldBe("LS+ $G public.data");

            // Propagate denied subscription — should NOT appear on wire
            manager.PropagateLocalSubscription("$G", "secret.data", null);

            // Send a PING to verify nothing else was sent
            manager.PropagateLocalSubscription("$G", "allowed.check", null);
            await PollHelper.YieldForAsync(100);
            var nextLine = await ReadLineAsync(remoteSocket, cts.Token);
            nextLine.ShouldBe("LS+ $G allowed.check");
        }
        finally
        {
            await manager.DisposeAsync();
        }
    }

    // ── ExportSubjects/ImportSubjects allow-list Unit Tests ────────────

    // Go: auth.go:127 SubjectPermission.Allow semantics
    [Fact]
    public void Allow_export_restricts_outbound_to_matching_subjects()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string>(),
            denyExports: [],
            denyImports: [],
            allowExports: ["orders.*", "events.>"],
            allowImports: []);

        mapper.IsSubjectAllowed("orders.created", LeafMapDirection.Outbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("orders.updated", LeafMapDirection.Outbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("events.system.boot", LeafMapDirection.Outbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("users.created", LeafMapDirection.Outbound).ShouldBeFalse();
        mapper.IsSubjectAllowed("admin.config", LeafMapDirection.Outbound).ShouldBeFalse();
    }

    // Go: auth.go:127 SubjectPermission.Allow semantics
    [Fact]
    public void Allow_import_restricts_inbound_to_matching_subjects()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string>(),
            denyExports: [],
            denyImports: [],
            allowExports: [],
            allowImports: ["metrics.*"]);

        mapper.IsSubjectAllowed("metrics.cpu", LeafMapDirection.Inbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("metrics.memory", LeafMapDirection.Inbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("logs.app", LeafMapDirection.Inbound).ShouldBeFalse();
    }

    // Go: auth.go:127 SubjectPermission — deny takes precedence over allow
    [Fact]
    public void Deny_takes_precedence_over_allow()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string>(),
            denyExports: ["orders.secret"],
            denyImports: [],
            allowExports: ["orders.*"],
            allowImports: []);

        // orders.created matches allow and not deny → permitted
        mapper.IsSubjectAllowed("orders.created", LeafMapDirection.Outbound).ShouldBeTrue();
        // orders.secret matches both allow and deny → deny wins
        mapper.IsSubjectAllowed("orders.secret", LeafMapDirection.Outbound).ShouldBeFalse();
    }

    // Go: auth.go:127 SubjectPermission — deny takes precedence over allow (import direction)
    [Fact]
    public void Deny_import_takes_precedence_over_allow_import()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string>(),
            denyExports: [],
            denyImports: ["metrics.secret"],
            allowExports: [],
            allowImports: ["metrics.*"]);

        mapper.IsSubjectAllowed("metrics.cpu", LeafMapDirection.Inbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("metrics.secret", LeafMapDirection.Inbound).ShouldBeFalse();
    }

    // Go: auth.go:127 SubjectPermission.Allow — empty allow-list means allow all
    [Fact]
    public void Empty_allow_lists_allow_everything_not_denied()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string>(),
            denyExports: [],
            denyImports: [],
            allowExports: [],
            allowImports: []);

        mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Outbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Inbound).ShouldBeTrue();
    }

    // Go: auth.go:127 SubjectPermission.Allow — wildcard patterns in allow-list
    [Fact]
    public void Allow_export_with_fwc_matches_deep_hierarchy()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string>(),
            denyExports: [],
            denyImports: [],
            allowExports: ["data.>"],
            allowImports: []);

        mapper.IsSubjectAllowed("data.x", LeafMapDirection.Outbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("data.x.y.z", LeafMapDirection.Outbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("other.x", LeafMapDirection.Outbound).ShouldBeFalse();
    }

    // Go: auth.go:127 SubjectPermission.Allow — bidirectional allow-lists are independent
    [Fact]
    public void Allow_lists_are_direction_independent()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string>(),
            denyExports: [],
            denyImports: [],
            allowExports: ["export.only"],
            allowImports: ["import.only"]);

        // export.only is allowed outbound, not restricted inbound (no inbound allow match required for it)
        mapper.IsSubjectAllowed("export.only", LeafMapDirection.Outbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("export.only", LeafMapDirection.Inbound).ShouldBeFalse();
        mapper.IsSubjectAllowed("import.only", LeafMapDirection.Outbound).ShouldBeFalse();
        mapper.IsSubjectAllowed("import.only", LeafMapDirection.Inbound).ShouldBeTrue();
    }

    // Go: auth.go:127 SubjectPermission.Allow — multiple allow patterns
    [Fact]
    public void Multiple_allow_patterns_any_match_permits()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string>(),
            denyExports: [],
            denyImports: [],
            allowExports: ["orders.*", "events.*", "metrics.>"],
            allowImports: []);

        mapper.IsSubjectAllowed("orders.new", LeafMapDirection.Outbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("events.created", LeafMapDirection.Outbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("metrics.cpu.avg", LeafMapDirection.Outbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("users.list", LeafMapDirection.Outbound).ShouldBeFalse();
    }

    // Go: auth.go:127 SubjectPermission — allow + deny combined with account mapping
    [Fact]
    public void Allow_with_account_mapping_and_deny()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string> { ["HUB"] = "SPOKE" },
            denyExports: ["orders.secret"],
            denyImports: [],
            allowExports: ["orders.*"],
            allowImports: []);

        var result = mapper.Map("HUB", "orders.new", LeafMapDirection.Outbound);
        result.Account.ShouldBe("SPOKE");
        result.Subject.ShouldBe("orders.new");

        mapper.IsSubjectAllowed("orders.new", LeafMapDirection.Outbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("orders.secret", LeafMapDirection.Outbound).ShouldBeFalse();
        mapper.IsSubjectAllowed("users.new", LeafMapDirection.Outbound).ShouldBeFalse();
    }

    // Go: auth.go:127 SubjectPermission.Allow — literal subjects in allow-list
    [Fact]
    public void Allow_export_with_literal_subject()
    {
        var mapper = new LeafHubSpokeMapper(
            new Dictionary<string, string>(),
            denyExports: [],
            denyImports: [],
            allowExports: ["status.health"],
            allowImports: []);

        mapper.IsSubjectAllowed("status.health", LeafMapDirection.Outbound).ShouldBeTrue();
        mapper.IsSubjectAllowed("status.ready", LeafMapDirection.Outbound).ShouldBeFalse();
    }

    // ── Integration: ExportSubjects allow-list blocks hub→leaf ────────

    // Go: auth.go:127 SubjectPermission.Allow — integration with server
    [Fact]
    public async Task ExportSubjects_allow_list_restricts_hub_to_leaf_forwarding()
    {
        var hubOptions = new NatsOptions
        {
            Host = "127.0.0.1",
            Port = 0,
            LeafNode = new LeafNodeOptions
            {
                Host = "127.0.0.1",
                Port = 0,
                ExportSubjects = ["allowed.>"],
            },
        };

        var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
        var hubCts = new CancellationTokenSource();
        _ = hub.StartAsync(hubCts.Token);
        await hub.WaitForReadyAsync();

        try
        {
            var spokeOptions = new NatsOptions
            {
                Host = "127.0.0.1",
                Port = 0,
                LeafNode = new LeafNodeOptions
                {
                    Host = "127.0.0.1",
                    Port = 0,
                    Remotes = [hub.LeafListen!],
                },
            };

            var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
            var spokeCts = new CancellationTokenSource();
            _ = spoke.StartAsync(spokeCts.Token);
            await spoke.WaitForReadyAsync();

            try
            {
                await PollHelper.WaitUntilAsync(() => !((hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0)), timeoutMs: 5000);

                await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
                await leafConn.ConnectAsync();
                await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
                await hubConn.ConnectAsync();

                await using var allowedSub = await leafConn.SubscribeCoreAsync<string>("allowed.data");
                await using var blockedSub = await leafConn.SubscribeCoreAsync<string>("blocked.data");
                await leafConn.PingAsync();
                await PollHelper.YieldForAsync(500);

                await hubConn.PublishAsync("allowed.data", "yes");
                await hubConn.PublishAsync("blocked.data", "no");

                using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
                (await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("yes");

                using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
                await Should.ThrowAsync<OperationCanceledException>(async () =>
                    await blockedSub.Msgs.ReadAsync(leakCts.Token));
            }
            finally
            {
                await spokeCts.CancelAsync();
                spoke.Dispose();
                spokeCts.Dispose();
            }
        }
        finally
        {
            await hubCts.CancelAsync();
            hub.Dispose();
            hubCts.Dispose();
        }
    }

    // Go: auth.go:127 SubjectPermission.Allow — import allow-list integration
    [Fact]
    public async Task ImportSubjects_allow_list_restricts_leaf_to_hub_forwarding()
    {
        var hubOptions = new NatsOptions
        {
            Host = "127.0.0.1",
            Port = 0,
            LeafNode = new LeafNodeOptions
            {
                Host = "127.0.0.1",
                Port = 0,
                ImportSubjects = ["allowed.>"],
            },
        };

        var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
        var hubCts = new CancellationTokenSource();
        _ = hub.StartAsync(hubCts.Token);
        await hub.WaitForReadyAsync();

        try
        {
            var spokeOptions = new NatsOptions
            {
                Host = "127.0.0.1",
                Port = 0,
                LeafNode = new LeafNodeOptions
                {
                    Host = "127.0.0.1",
                    Port = 0,
                    Remotes = [hub.LeafListen!],
                },
            };

            var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
            var spokeCts = new CancellationTokenSource();
            _ = spoke.StartAsync(spokeCts.Token);
            await spoke.WaitForReadyAsync();

            try
            {
                await PollHelper.WaitUntilAsync(() => !((hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0)), timeoutMs: 5000);

                await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
                await hubConn.ConnectAsync();
                await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
                await leafConn.ConnectAsync();

                await using var allowedSub = await hubConn.SubscribeCoreAsync<string>("allowed.data");
                await using var blockedSub = await hubConn.SubscribeCoreAsync<string>("blocked.data");
                await hubConn.PingAsync();
                await PollHelper.YieldForAsync(500);

                await leafConn.PublishAsync("allowed.data", "yes");
                await leafConn.PublishAsync("blocked.data", "no");

                using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
                (await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("yes");

                using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
                await Should.ThrowAsync<OperationCanceledException>(async () =>
                    await blockedSub.Msgs.ReadAsync(leakCts.Token));
            }
            finally
            {
                await spokeCts.CancelAsync();
                spoke.Dispose();
                spokeCts.Dispose();
            }
        }
        finally
        {
            await hubCts.CancelAsync();
            hub.Dispose();
            hubCts.Dispose();
        }
    }

    // ── Wire-level: ExportSubjects blocks LS+ propagation ────────────

    // Go: auth.go:127 SubjectPermission.Allow — subscription propagation filtered by allow-list
    [Fact]
    public async Task ExportSubjects_blocks_subscription_propagation_for_non_allowed()
    {
        var options = new LeafNodeOptions
        {
            Host = "127.0.0.1",
            Port = 0,
            ExportSubjects = ["allowed.*"],
        };

        var manager = new LeafNodeManager(
            options,
            new ServerStats(),
            "HUB1",
            _ => { },
            _ => { },
            NullLogger<LeafNodeManager>.Instance);

        await manager.StartAsync(CancellationToken.None);
        try
        {
            using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            await remoteSocket.ConnectAsync(IPAddress.Loopback, options.Port);

            using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
            await WriteLineAsync(remoteSocket, "LEAF SPOKE1", cts.Token);
            var line = await ReadLineAsync(remoteSocket, cts.Token);
            line.ShouldStartWith("LEAF ");

            await PollHelper.YieldForAsync(200);

            // Propagate allowed subscription
            manager.PropagateLocalSubscription("$G", "allowed.data", null);
            await PollHelper.YieldForAsync(100);
            var lsLine = await ReadLineAsync(remoteSocket, cts.Token);
            lsLine.ShouldBe("LS+ $G allowed.data");

            // Propagate non-allowed subscription — should NOT appear on wire
            manager.PropagateLocalSubscription("$G", "blocked.data", null);

            // Verify by sending another allowed subscription
            manager.PropagateLocalSubscription("$G", "allowed.check", null);
            await PollHelper.YieldForAsync(100);
            var nextLine = await ReadLineAsync(remoteSocket, cts.Token);
            nextLine.ShouldBe("LS+ $G allowed.check");
        }
        finally
        {
            await manager.DisposeAsync();
        }
    }

    // ── Helpers ────────────────────────────────────────────────────────

    private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
    {
        var bytes = new List<byte>(64);
        var single = new byte[1];
        while (true)
        {
            var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
            if (read == 0)
                break;
            if (single[0] == (byte)'\n')
                break;
            if (single[0] != (byte)'\r')
                bytes.Add(single[0]);
        }

        return Encoding.ASCII.GetString([.. bytes]);
    }

    private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
        => socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
}
