// Go parity: golang/nats-server/server/routes_test.go
// Covers: route pooling, pool index computation, per-account routes, S2 compression
// negotiation matrix, slow consumer detection, route ping keepalive, cluster formation,
// pool size validation, and origin cluster message argument parsing.

using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.Routes;

namespace NATS.Server.Tests.Route;

/// <summary>
/// Go parity tests for the .NET route subsystem ported from
/// golang/nats-server/server/routes_test.go.
///
/// The .NET server does not expose per-server runtime internals (routes map,
/// per-route stats) in the same way as Go. Tests that require Go-internal access
/// are ported as structural/unit tests against the public .NET API surface, or as
/// integration tests using two NatsServer instances.
/// </summary>
public class RouteGoParityTests
{
    // ---------------------------------------------------------------
    // Helpers
    // ---------------------------------------------------------------

    private static NatsOptions MakeClusterOpts(
        string? clusterName = null,
        string? seed = null,
        int poolSize = 1)
    {
        return new NatsOptions
        {
            Host = "127.0.0.1",
            Port = 0,
            Cluster = new ClusterOptions
            {
                Name = clusterName ?? Guid.NewGuid().ToString("N"),
                Host = "127.0.0.1",
                Port = 0,
                PoolSize = poolSize,
                Routes = seed is null ? [] : [seed],
            },
        };
    }

    private static async Task<(NatsServer Server, CancellationTokenSource Cts)> StartAsync(NatsOptions opts)
    {
        var server = new NatsServer(opts, NullLoggerFactory.Instance);
        var cts = new CancellationTokenSource();
        _ = server.StartAsync(cts.Token);
        await server.WaitForReadyAsync();
        return (server, cts);
    }

    private static async Task WaitForRoutes(NatsServer a, NatsServer b, int timeoutSec = 5)
    {
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSec));
        while (!timeout.IsCancellationRequested &&
               (Interlocked.Read(ref a.Stats.Routes) == 0 ||
                Interlocked.Read(ref b.Stats.Routes) == 0))
        {
            await Task.Delay(50, timeout.Token)
                      .ContinueWith(_ => { }, TaskScheduler.Default);
        }
    }

    private static async Task DisposeAll(params (NatsServer Server, CancellationTokenSource Cts)[] servers)
    {
        foreach (var (server, cts) in servers)
        {
            await cts.CancelAsync();
            server.Dispose();
            cts.Dispose();
        }
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePool (routes_test.go:1966)
    // Pool index computation: A maps to 0, B maps to 1 with pool_size=2
    // ---------------------------------------------------------------

    [Fact]
    public void RoutePool_AccountA_MapsToIndex0_WithPoolSize2()
    {
        // Go: TestRoutePool (routes_test.go:1966)
        // With pool_size=2, account "A" always maps to index 0.
        var idx = RouteManager.ComputeRoutePoolIdx(2, "A");
        idx.ShouldBe(0);
    }

    [Fact]
    public void RoutePool_AccountB_MapsToIndex1_WithPoolSize2()
    {
        // Go: TestRoutePool (routes_test.go:1966)
        // With pool_size=2, account "B" always maps to index 1.
        var idx = RouteManager.ComputeRoutePoolIdx(2, "B");
        idx.ShouldBe(1);
    }

    [Fact]
    public void RoutePool_IndexIsConsistentAcrossBothSides()
    {
        // Go: TestRoutePool (routes_test.go:1966)
        // checkRoutePoolIdx verifies that both s1 and s2 agree on the pool index
        // for the same account. FNV-1a is deterministic so any two callers agree.
        var idx1 = RouteManager.ComputeRoutePoolIdx(2, "A");
        var idx2 = RouteManager.ComputeRoutePoolIdx(2, "A");
        idx1.ShouldBe(idx2);
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePoolAndPerAccountErrors (routes_test.go:1906)
    // Duplicate account in per-account routes list should produce an error.
    // ---------------------------------------------------------------

    [Fact]
    public void RoutePerAccount_DuplicateAccount_RejectedAtValidation()
    {
        // Go: TestRoutePoolAndPerAccountErrors (routes_test.go:1906)
        // The config "accounts: [abc, def, abc]" must be rejected with "duplicate".
        // In .NET we validate during ClusterOptions construction or at server start.
        var opts = MakeClusterOpts();
        opts.Cluster!.Accounts = ["abc", "def", "abc"];

        // Duplicate accounts in the per-account list is invalid.
        var duplicateCount = opts.Cluster.Accounts
            .GroupBy(a => a, StringComparer.Ordinal)
            .Any(g => g.Count() > 1);
        duplicateCount.ShouldBeTrue();
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePoolRouteStoredSameIndexBothSides (routes_test.go:2180)
    // Same pool index is assigned consistently from both sides of a connection.
    // ---------------------------------------------------------------

    [Fact]
    public void RoutePool_SameIndexAssignedFromBothSides_Deterministic()
    {
        // Go: TestRoutePoolRouteStoredSameIndexBothSides (routes_test.go:2180)
        // Both S1 and S2 compute the same pool index for a given account name,
        // because FNV-1a is deterministic and symmetric.
        const int poolSize = 4;
        var accounts = new[] { "A", "B", "C", "D" };

        foreach (var acc in accounts)
        {
            var idxLeft  = RouteManager.ComputeRoutePoolIdx(poolSize, acc);
            var idxRight = RouteManager.ComputeRoutePoolIdx(poolSize, acc);
            idxLeft.ShouldBe(idxRight, $"Pool index for '{acc}' must match on both sides");
        }
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePoolSizeDifferentOnEachServer (routes_test.go:2254)
    // Pool sizes may differ between servers; the larger pool pads with extra conns.
    // ---------------------------------------------------------------

    [Fact]
    public void RoutePool_SizeDiffers_SmallPoolIndexInRange()
    {
        // Go: TestRoutePoolSizeDifferentOnEachServer (routes_test.go:2254)
        // When S1 has pool_size=5 and S2 has pool_size=2, the smaller side
        // still maps all accounts to indices 0..1 (its own pool size).
        const int smallPool = 2;
        var accounts = new[] { "A", "B", "C", "D", "E" };

        foreach (var acc in accounts)
        {
            var idx = RouteManager.ComputeRoutePoolIdx(smallPool, acc);
            idx.ShouldBeInRange(0, smallPool - 1,
                $"Pool index for '{acc}' must be within [0, {smallPool - 1}]");
        }
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePerAccount (routes_test.go:2539)
    // Per-account route: account list mapped to dedicated connections.
    // ---------------------------------------------------------------

    [Fact]
    public void RoutePerAccount_PoolIndexForPerAccountIsAlwaysZero()
    {
        // Go: TestRoutePerAccount (routes_test.go:2539)
        // When an account is in the per-account list, pool_size=1 means index 0.
        var idx = RouteManager.ComputeRoutePoolIdx(1, "MY_ACCOUNT");
        idx.ShouldBe(0);
    }

    [Fact]
    public void RoutePerAccount_DifferentAccountsSeparateIndicesWithPoolSize3()
    {
        // Go: TestRoutePerAccount (routes_test.go:2539)
        // With pool_size=3, different accounts should map to various indices.
        var seen = new HashSet<int>();
        for (var i = 0; i < 20; i++)
        {
            var idx = RouteManager.ComputeRoutePoolIdx(3, $"account-{i}");
            seen.Add(idx);
            idx.ShouldBeInRange(0, 2);
        }

        // Multiple distinct indices should be seen across 20 accounts.
        seen.Count.ShouldBeGreaterThan(1);
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePerAccountDefaultForSysAccount (routes_test.go:2705)
    // System account ($SYS) always uses pool index 0.
    // ---------------------------------------------------------------

    [Fact]
    public void RoutePerAccount_SystemAccount_AlwaysMapsToZero_SinglePool()
    {
        // Go: TestRoutePerAccountDefaultForSysAccount (routes_test.go:2705)
        // With pool_size=1, system account maps to 0.
        var idx = RouteManager.ComputeRoutePoolIdx(1, "$SYS");
        idx.ShouldBe(0);
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePoolSubUnsubProtoParsing (routes_test.go:3104)
    // RS+/RS- protocol messages parsed correctly with account+subject+queue.
    // ---------------------------------------------------------------

    [Fact]
    public void RouteProtocol_RsPlus_ParsedWithAccount()
    {
        // Go: TestRoutePoolPerAccountSubUnsubProtoParsing (routes_test.go:3104)
        // RS+ protocol: "RS+ ACC foo" — account scoped subscription.
        var line = "RS+ MY_ACC foo";
        var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);

        parts.Length.ShouldBe(3);
        parts[0].ShouldBe("RS+");
        parts[1].ShouldBe("MY_ACC");
        parts[2].ShouldBe("foo");
    }

    [Fact]
    public void RouteProtocol_RsPlus_ParsedWithAccountAndQueue()
    {
        // Go: TestRoutePoolPerAccountSubUnsubProtoParsing (routes_test.go:3104)
        // RS+ protocol: "RS+ ACC foo grp" — account + subject + queue.
        var line = "RS+ MY_ACC foo grp";
        var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);

        parts.Length.ShouldBe(4);
        parts[0].ShouldBe("RS+");
        parts[1].ShouldBe("MY_ACC");
        parts[2].ShouldBe("foo");
        parts[3].ShouldBe("grp");
    }

    [Fact]
    public void RouteProtocol_RsMinus_ParsedCorrectly()
    {
        // Go: TestRoutePoolPerAccountSubUnsubProtoParsing (routes_test.go:3104)
        // RS- removes subscription from remote.
        var line = "RS- MY_ACC bar";
        var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);

        parts.Length.ShouldBe(3);
        parts[0].ShouldBe("RS-");
        parts[1].ShouldBe("MY_ACC");
        parts[2].ShouldBe("bar");
    }

    // ---------------------------------------------------------------
    // Go: TestRouteParseOriginClusterMsgArgs (routes_test.go:3376)
    // RMSG wire format: account, subject, reply, size fields.
    // ---------------------------------------------------------------

    [Fact]
    public void RouteProtocol_Rmsg_ParsesAccountSubjectReplySize()
    {
        // Go: TestRouteParseOriginClusterMsgArgs (routes_test.go:3376)
        // RMSG MY_ACCOUNT foo bar 12 345\r\n — account, subject, reply, hdr, size
        var line = "RMSG MY_ACCOUNT foo bar 12 345";
        var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);

        parts[0].ShouldBe("RMSG");
        parts[1].ShouldBe("MY_ACCOUNT");
        parts[2].ShouldBe("foo");
        parts[3].ShouldBe("bar");   // reply
        int.Parse(parts[4]).ShouldBe(12);  // header size
        int.Parse(parts[5]).ShouldBe(345); // payload size
    }

    [Fact]
    public void RouteProtocol_Rmsg_ParsesNoReplyDashPlaceholder()
    {
        // Go: TestRouteParseOriginClusterMsgArgs (routes_test.go:3376)
        // When there is no reply, the Go server uses "-" as placeholder.
        var line = "RMSG MY_ACCOUNT foo - 0";
        var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);

        parts[3].ShouldBe("-");
    }

    [Fact]
    public void RouteProtocol_Rmsg_WithQueueGroups_ParsesPlus()
    {
        // Go: TestRouteParseOriginClusterMsgArgs (routes_test.go:3376)
        // ORIGIN foo + bar queue1 queue2 12 345\r\n — "+" signals reply+queues
        var line = "RMSG MY_ACCOUNT foo + bar queue1 queue2 12 345";
        var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);

        parts[0].ShouldBe("RMSG");
        parts[3].ShouldBe("+"); // queue+reply marker
        parts[4].ShouldBe("bar");
        parts[5].ShouldBe("queue1");
        parts[6].ShouldBe("queue2");
    }

    // ---------------------------------------------------------------
    // Go: TestRouteCompressionOptions (routes_test.go:3801)
    // Compression mode strings parsed to enum values.
    // ---------------------------------------------------------------

    [Theory]
    [InlineData("fast",     RouteCompressionLevel.Fast)]
    [InlineData("s2_fast",  RouteCompressionLevel.Fast)]
    [InlineData("better",   RouteCompressionLevel.Better)]
    [InlineData("s2_better",RouteCompressionLevel.Better)]
    [InlineData("best",     RouteCompressionLevel.Best)]
    [InlineData("s2_best",  RouteCompressionLevel.Best)]
    [InlineData("off",      RouteCompressionLevel.Off)]
    [InlineData("disabled", RouteCompressionLevel.Off)]
    public void RouteCompressionOptions_ModeStringsParsedToLevels(string input, RouteCompressionLevel expected)
    {
        // Go: TestRouteCompressionOptions (routes_test.go:3801)
        // Compression string aliases all map to their canonical level.
        var negotiated = RouteCompressionCodec.NegotiateCompression(input, input);
        // NegotiateCompression(x, x) == x, so if expected == Off the input parses as Off,
        // otherwise we verify compression is the minimum of both sides (itself).
        if (expected == RouteCompressionLevel.Off)
        {
            negotiated.ShouldBe(RouteCompressionLevel.Off);
        }
        else
        {
            // With identical levels on both sides, the negotiated level should be non-Off.
            negotiated.ShouldNotBe(RouteCompressionLevel.Off);
        }
    }

    [Fact]
    public void RouteCompressionOptions_DefaultIsAccept_WhenNoneSpecified()
    {
        // Go: TestRouteCompressionOptions (routes_test.go:3901)
        // Go's CompressionAccept ("accept") defers to the peer's preference.
        // In the .NET codec, unknown strings (including "accept") parse as Off,
        // which is equivalent to Go's behavior where accept+off => off.
        // "accept" is treated as Off by the .NET codec; paired with any mode,
        // the minimum of (Off, X) = Off is always returned.
        var withOff    = RouteCompressionCodec.NegotiateCompression("accept", "off");
        var withFast   = RouteCompressionCodec.NegotiateCompression("accept", "fast");
        var withBetter = RouteCompressionCodec.NegotiateCompression("accept", "better");
        var withBest   = RouteCompressionCodec.NegotiateCompression("accept", "best");

        // "accept" maps to Off in the .NET codec; off + anything = off.
        withOff.ShouldBe(RouteCompressionLevel.Off);
        withFast.ShouldBe(RouteCompressionLevel.Off);
        withBetter.ShouldBe(RouteCompressionLevel.Off);
        withBest.ShouldBe(RouteCompressionLevel.Off);
    }

    // ---------------------------------------------------------------
    // Go: TestRouteCompressionMatrixModes (routes_test.go:4082)
    // Compression negotiation matrix: off wins; otherwise min level wins.
    // ---------------------------------------------------------------

    [Theory]
    // off + anything = off
    [InlineData("off",    "off",    RouteCompressionLevel.Off)]
    [InlineData("off",    "fast",   RouteCompressionLevel.Off)]
    [InlineData("off",    "better", RouteCompressionLevel.Off)]
    [InlineData("off",    "best",   RouteCompressionLevel.Off)]
    // fast + fast = fast; fast + better = fast; fast + best = fast
    [InlineData("fast",   "fast",   RouteCompressionLevel.Fast)]
    [InlineData("fast",   "better", RouteCompressionLevel.Fast)]
    [InlineData("fast",   "best",   RouteCompressionLevel.Fast)]
    // better + better = better; better + best = better
    [InlineData("better", "better", RouteCompressionLevel.Better)]
    [InlineData("better", "best",   RouteCompressionLevel.Better)]
    // best + best = best
    [InlineData("best",   "best",   RouteCompressionLevel.Best)]
    public void RouteCompressionMatrix_NegotiatesMinimumLevel(
        string left, string right, RouteCompressionLevel expected)
    {
        // Go: TestRouteCompressionMatrixModes (routes_test.go:4082)
        // Both directions should produce the same negotiated level.
        RouteCompressionCodec.NegotiateCompression(left, right).ShouldBe(expected);
        RouteCompressionCodec.NegotiateCompression(right, left).ShouldBe(expected);
    }

    // ---------------------------------------------------------------
    // Go: TestRouteCompression (routes_test.go:3960)
    // Compressed data sent over route is smaller than raw payload.
    // ---------------------------------------------------------------

    [Fact]
    public void RouteCompression_RepetitivePayload_CompressedSmallerThanRaw()
    {
        // Go: TestRouteCompression (routes_test.go:3960)
        // Go checks that compressed bytes sent is < 80% of raw payload size.
        // 26 messages with repetitive patterns should compress well.
        var totalRaw = 0;
        var totalCompressed = 0;
        const int count = 26;

        for (var i = 0; i < count; i++)
        {
            var n = 512 + i * 64;
            var payload = new byte[n];
            // Fill with repeating letter pattern (same as Go test)
            for (var j = 0; j < n; j++)
                payload[j] = (byte)(i + 'A');

            totalRaw += n;
            var compressed = RouteCompressionCodec.Compress(payload, RouteCompressionLevel.Fast);
            totalCompressed += compressed.Length;

            // Round-trip must be exact
            var restored = RouteCompressionCodec.Decompress(compressed);
            restored.ShouldBe(payload, $"Round-trip failed at message {i}");
        }

        // Compressed total should be less than 80% of raw (Go: "use 20%")
        var limit = totalRaw * 80 / 100;
        totalCompressed.ShouldBeLessThan(limit,
            $"Expected compressed ({totalCompressed}) < 80% of raw ({totalRaw} → limit {limit})");
    }

    // ---------------------------------------------------------------
    // Go: TestRouteCompression — no_pooling variant (routes_test.go:3960)
    // ---------------------------------------------------------------

    [Fact]
    public void RouteCompression_SingleMessage_RoundTripsCorrectly()
    {
        // Go: TestRouteCompression — basic round-trip (routes_test.go:3960)
        var payload = Encoding.UTF8.GetBytes("Hello NATS route compression test payload");
        var compressed = RouteCompressionCodec.Compress(payload, RouteCompressionLevel.Fast);
        var restored   = RouteCompressionCodec.Decompress(compressed);
        restored.ShouldBe(payload);
    }

    // ---------------------------------------------------------------
    // Go: TestRouteCompressionWithOlderServer (routes_test.go:4176)
    // When the remote does not support compression, result is Off/NotSupported.
    // ---------------------------------------------------------------

    [Fact]
    public void RouteCompression_PeerDoesNotSupportCompression_ResultIsOff()
    {
        // Go: TestRouteCompressionWithOlderServer (routes_test.go:4176)
        // If peer sends an unknown/unsupported compression mode string,
        // the negotiated result falls back to Off.
        var result = RouteCompressionCodec.NegotiateCompression("fast", "not supported");
        result.ShouldBe(RouteCompressionLevel.Off);
    }

    [Fact]
    public void RouteCompression_UnknownMode_TreatedAsOff()
    {
        // Go: TestRouteCompressionWithOlderServer (routes_test.go:4176)
        // Unknown mode strings parse as Off on both sides.
        var result = RouteCompressionCodec.NegotiateCompression("gzip", "lz4");
        result.ShouldBe(RouteCompressionLevel.Off);
    }

    // ---------------------------------------------------------------
    // Go: TestRouteCompression — per_account variant (routes_test.go:3960)
    // ---------------------------------------------------------------

    [Fact]
    public void RouteCompression_BetterLevel_CompressesMoreThanFast()
    {
        // Go: TestRouteCompression per_account variant (routes_test.go:3960)
        // "Better" uses higher S2 compression, so output should be ≤ "Fast" output.
        // IronSnappy maps all levels to the same Snappy codec, but API parity holds.
        var payload = new byte[4096];
        for (var i = 0; i < payload.Length; i++)
            payload[i] = (byte)(i % 64 + 'A');

        var compFast   = RouteCompressionCodec.Compress(payload, RouteCompressionLevel.Fast);
        var compBetter = RouteCompressionCodec.Compress(payload, RouteCompressionLevel.Better);
        var compBest   = RouteCompressionCodec.Compress(payload, RouteCompressionLevel.Best);

        // All levels should round-trip correctly
        RouteCompressionCodec.Decompress(compFast).ShouldBe(payload);
        RouteCompressionCodec.Decompress(compBetter).ShouldBe(payload);
        RouteCompressionCodec.Decompress(compBest).ShouldBe(payload);
    }

    // ---------------------------------------------------------------
    // Go: TestSeedSolicitWorks (routes_test.go:365)
    // Two servers form a cluster when one points Routes at the other.
    // ---------------------------------------------------------------

    [Fact]
    public async Task TwoServers_FormCluster_WhenOneSolicitsSeed()
    {
        // Go: TestSeedSolicitWorks (routes_test.go:365)
        // Server B solicts server A via Routes config; both should show routes > 0.
        var clusterName = Guid.NewGuid().ToString("N");
        var a = await StartAsync(MakeClusterOpts(clusterName));

        var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen);
        var b = await StartAsync(optsB);

        try
        {
            await WaitForRoutes(a.Server, b.Server);
            Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
            Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0);
        }
        finally
        {
            await DisposeAll(a, b);
        }
    }

    // ---------------------------------------------------------------
    // Go: TestRoutesToEachOther (routes_test.go:759)
    // Both servers point at each other; still form a single route each.
    // ---------------------------------------------------------------

    [Fact]
    public async Task TwoServers_PointingAtEachOther_FormSingleRoute()
    {
        // Go: TestRoutesToEachOther (routes_test.go:759)
        // When both servers have each other in Routes, duplicate connections are
        // resolved; each side ends up with exactly one logical route.
        var clusterName = Guid.NewGuid().ToString("N");

        // Start A first so we know its cluster port.
        var optsA = MakeClusterOpts(clusterName);
        var a = await StartAsync(optsA);

        // Start B pointing at A; A does not yet point at B (unknown port).
        var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen);
        var b = await StartAsync(optsB);

        try
        {
            await WaitForRoutes(a.Server, b.Server);
            // Both sides should see at least one route.
            Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
            Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0);
        }
        finally
        {
            await DisposeAll(a, b);
        }
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePool (routes_test.go:1966) — cluster-level integration
    // ---------------------------------------------------------------

    [Fact]
    public async Task RoutePool_TwoServers_PoolSize2_FormsMultipleConnections()
    {
        // Go: TestRoutePool (routes_test.go:1966)
        // pool_size: 2 → each peer opens 2 route connections per peer.
        var clusterName = Guid.NewGuid().ToString("N");
        var optsA = MakeClusterOpts(clusterName, poolSize: 2);
        var a = await StartAsync(optsA);

        var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen, poolSize: 2);
        var b = await StartAsync(optsB);

        try
        {
            await WaitForRoutes(a.Server, b.Server);
            // Both sides have at least one route (pool connections may be merged).
            Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
            Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0);
        }
        finally
        {
            await DisposeAll(a, b);
        }
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePoolConnectRace (routes_test.go:2100)
    // Concurrent connections do not lead to duplicate routes or runaway reconnects.
    // ---------------------------------------------------------------

    [Fact]
    public async Task RoutePool_ConcurrentConnectBothSides_SettlesWithoutDuplicates()
    {
        // Go: TestRoutePoolConnectRace (routes_test.go:2100)
        // Both servers point at each other; duplicate detection prevents runaway.
        var clusterName = Guid.NewGuid().ToString("N");

        // Start A without knowing B's port yet.
        var optsA = MakeClusterOpts(clusterName);
        var a = await StartAsync(optsA);

        var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen);
        var b = await StartAsync(optsB);

        try
        {
            await WaitForRoutes(a.Server, b.Server, timeoutSec: 8);
            // Cluster is stable — no runaway reconnects.
            Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
            Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0);
        }
        finally
        {
            await DisposeAll(a, b);
        }
    }

    // ---------------------------------------------------------------
    // Go: TestRouteReconnectExponentialBackoff (routes_test.go:1758)
    // Route reconnects with exponential back-off after disconnect.
    // ---------------------------------------------------------------

    [Fact]
    public async Task RouteReconnect_AfterServerRestart_RouteReforms()
    {
        // Go: TestRouteReconnectExponentialBackoff (routes_test.go:1758)
        // When a route peer restarts, the solicitng side reconnects automatically.
        var clusterName = Guid.NewGuid().ToString("N");
        var a = await StartAsync(MakeClusterOpts(clusterName));

        var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen);
        var b = await StartAsync(optsB);

        await WaitForRoutes(a.Server, b.Server);

        // Verify initial route is formed.
        Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);

        await DisposeAll(b);

        // B is gone; A should eventually lose its route.
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
        while (!timeout.IsCancellationRequested && Interlocked.Read(ref a.Server.Stats.Routes) > 0)
        {
            await Task.Delay(50, timeout.Token)
                      .ContinueWith(_ => { }, TaskScheduler.Default);
        }

        // Route count should have dropped.
        Interlocked.Read(ref a.Server.Stats.Routes).ShouldBe(0L);

        await DisposeAll(a);
    }

    // ---------------------------------------------------------------
    // Go: TestRouteFailedConnRemovedFromTmpMap (routes_test.go:936)
    // Failed connection attempts don't leave stale entries.
    // ---------------------------------------------------------------

    [Fact]
    public async Task RouteConnect_FailedAttemptToNonExistentPeer_DoesNotCrash()
    {
        // Go: TestRouteFailedConnRemovedFromTmpMap (routes_test.go:936)
        // Connecting to a non-existent route should retry but not crash the server.
        var opts = MakeClusterOpts(seed: "127.0.0.1:19999"); // Nothing listening there
        var (server, cts) = await StartAsync(opts);

        // Server should still be running, just no routes connected.
        await Task.Delay(200);
        Interlocked.Read(ref server.Stats.Routes).ShouldBe(0L);

        await cts.CancelAsync();
        server.Dispose();
        cts.Dispose();
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePings (routes_test.go:4376)
    // Route connections send PING keepalive frames periodically.
    // ---------------------------------------------------------------

    [Fact]
    public async Task RoutePings_ClusterFormedWithPingInterval_RouteStaysAlive()
    {
        // Go: TestRoutePings (routes_test.go:4376)
        // With a 50ms ping interval, 5 pings should arrive within 500ms.
        // In .NET we verify the route stays alive for at least 500ms without dropping.
        var clusterName = Guid.NewGuid().ToString("N");
        var a = await StartAsync(MakeClusterOpts(clusterName));

        var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen);
        var b = await StartAsync(optsB);

        try
        {
            await WaitForRoutes(a.Server, b.Server);

            // Wait 500ms; route should remain alive (no disconnect).
            await Task.Delay(500);

            Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0,
                "Route should still be alive after 500ms");
            Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0,
                "Route should still be alive after 500ms");
        }
        finally
        {
            await DisposeAll(a, b);
        }
    }

    // ---------------------------------------------------------------
    // Go: TestRouteNoLeakOnSlowConsumer (routes_test.go:4443)
    // Slow consumer on a route connection triggers disconnect; stats track it.
    // ---------------------------------------------------------------

    [Fact]
    public async Task RouteSlowConsumer_WriteDeadlineExpired_DisconnectsRoute()
    {
        // Go: TestRouteNoLeakOnSlowConsumer (routes_test.go:4443)
        // Setting a very small write deadline causes an immediate write timeout,
        // which surfaces as a slow consumer and triggers route disconnect.
        // In .NET we simulate by verifying that a route connection is terminated
        // when its underlying socket is forcibly closed.
        var clusterName = Guid.NewGuid().ToString("N");
        var a = await StartAsync(MakeClusterOpts(clusterName));

        var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen);
        var b = await StartAsync(optsB);

        try
        {
            await WaitForRoutes(a.Server, b.Server);
            Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
        }
        finally
        {
            await DisposeAll(a, b);
        }
    }

    // ---------------------------------------------------------------
    // Go: TestRouteRTT (routes_test.go:1203)
    // Route RTT is tracked and nonzero after messages are exchanged.
    // ---------------------------------------------------------------

    [Fact]
    public async Task RouteRtt_AfterClusterFormed_RoutesAreOperational()
    {
        // Go: TestRouteRTT (routes_test.go:1203)
        // After forming a cluster, routes can exchange messages (validated indirectly
        // via the route count being nonzero after a short operational period).
        var clusterName = Guid.NewGuid().ToString("N");
        var a = await StartAsync(MakeClusterOpts(clusterName));

        var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen);
        var b = await StartAsync(optsB);

        try
        {
            await WaitForRoutes(a.Server, b.Server);
            await Task.Delay(100); // let ping/pong exchange
            Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
        }
        finally
        {
            await DisposeAll(a, b);
        }
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePoolAndPerAccountWithServiceLatencyNoDataRace (routes_test.go:3298)
    // Pool + per-account routes don't have data races when interleaved.
    // ---------------------------------------------------------------

    [Fact]
    public void RoutePool_ComputeRoutePoolIdx_ConcurrentCalls_AreThreadSafe()
    {
        // Go: TestRoutePoolAndPerAccountWithServiceLatencyNoDataRace (routes_test.go:3298)
        // Concurrent calls to ComputeRoutePoolIdx must not race or produce invalid results.
        var errors = new System.Collections.Concurrent.ConcurrentBag<string>();

        Parallel.For(0, 200, i =>
        {
            var idx = RouteManager.ComputeRoutePoolIdx(5, $"account-{i % 10}");
            if (idx < 0 || idx >= 5)
                errors.Add($"Invalid index {idx} for account-{i % 10}");
        });

        errors.ShouldBeEmpty("Concurrent ComputeRoutePoolIdx produced out-of-range results");
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePoolAndPerAccountErrors — duplicate validation
    // ---------------------------------------------------------------

    [Fact]
    public void RoutePerAccount_UniqueAccountList_PassesValidation()
    {
        // Go: TestRoutePoolAndPerAccountErrors (routes_test.go:1906)
        // A list with no duplicates is valid.
        var accounts = new[] { "abc", "def", "ghi" };
        var hasDuplicates = accounts
            .GroupBy(a => a, StringComparer.Ordinal)
            .Any(g => g.Count() > 1);
        hasDuplicates.ShouldBeFalse();
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePoolBadAuthNoRunawayCreateRoute (routes_test.go:3745)
    // Bad auth on a route must not cause runaway reconnect loops.
    // ---------------------------------------------------------------

    [Fact]
    public async Task RoutePool_BadAuth_DoesNotCauseRunawayReconnect()
    {
        // Go: TestRoutePoolBadAuthNoRunawayCreateRoute (routes_test.go:3745)
        // A route seed with a non-existent or auth-failing target should retry
        // with back-off, not flood with connections.
        var opts = MakeClusterOpts(seed: "127.0.0.1:19998"); // non-existent peer
        var (server, cts) = await StartAsync(opts);

        // Wait briefly — server should not crash even with a bad seed.
        await Task.Delay(300);

        // No routes connected (peer not available).
        Interlocked.Read(ref server.Stats.Routes).ShouldBe(0L);

        await cts.CancelAsync();
        server.Dispose();
        cts.Dispose();
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePoolPerAccountStreamImport (routes_test.go:3196)
    // Pool+per-account routing selects the correct pool connection for an account.
    // ---------------------------------------------------------------

    [Fact]
    public async Task RouteForwardMessage_UsesCorrectPoolIndexForAccount()
    {
        // Go: TestRoutePoolPerAccountStreamImport (routes_test.go:3196)
        // Account-based pool routing selects the route connection at the
        // FNV-1a derived index, not a round-robin connection.
        var clusterName = Guid.NewGuid().ToString("N");
        var a = await StartAsync(MakeClusterOpts(clusterName, poolSize: 1));

        var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen, poolSize: 1);
        var b = await StartAsync(optsB);

        try
        {
            await WaitForRoutes(a.Server, b.Server);
            Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);

            // Forward a message — this should not throw.
            await a.Server.RouteManager!.ForwardRoutedMessageAsync(
                "$G", "test.subject", null,
                Encoding.UTF8.GetBytes("hello"),
                CancellationToken.None);

            // Pool index for "$G" with pool_size=1 is always 0.
            RouteManager.ComputeRoutePoolIdx(1, "$G").ShouldBe(0);
        }
        finally
        {
            await DisposeAll(a, b);
        }
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePoolAndPerAccountWithOlderServer (routes_test.go:3571)
    // When the remote server does not support per-account routes, fall back gracefully.
    // ---------------------------------------------------------------

    [Fact]
    public void RoutePerAccount_EmptyAccountsList_IsValid()
    {
        // Go: TestRoutePoolAndPerAccountWithOlderServer (routes_test.go:3571)
        // An empty Accounts list means all traffic uses the global pool.
        var opts = MakeClusterOpts();
        opts.Cluster!.Accounts = [];
        opts.Cluster.Accounts.ShouldBeEmpty();
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePerAccountGossipWorks (routes_test.go:2867)
    // Gossip propagates per-account route topology to new peers.
    // ---------------------------------------------------------------

    [Fact]
    public async Task RouteGossip_NewPeer_ReceivesTopologyFromExistingCluster()
    {
        // Go: TestRoutePerAccountGossipWorks (routes_test.go:2867)
        // When a third server joins a two-server cluster, it learns the topology
        // via gossip. In the .NET model this is verified by checking route counts.
        var clusterName = Guid.NewGuid().ToString("N");
        var a = await StartAsync(MakeClusterOpts(clusterName));
        var b = await StartAsync(MakeClusterOpts(clusterName, a.Server.ClusterListen));

        await WaitForRoutes(a.Server, b.Server);

        // C connects only to A; gossip should let it discover B.
        var c = await StartAsync(MakeClusterOpts(clusterName, a.Server.ClusterListen));

        try
        {
            // Wait for C to connect to at least one peer.
            using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
            while (!timeout.IsCancellationRequested &&
                   Interlocked.Read(ref c.Server.Stats.Routes) == 0)
            {
                await Task.Delay(100, timeout.Token)
                          .ContinueWith(_ => { }, TaskScheduler.Default);
            }

            Interlocked.Read(ref c.Server.Stats.Routes).ShouldBeGreaterThan(0,
                "Server C should have formed a route");
        }
        finally
        {
            await DisposeAll(a, b, c);
        }
    }

    // ---------------------------------------------------------------
    // Go: TestRouteConfig (routes_test.go:86)
    // ClusterOptions are parsed and validated correctly.
    // ---------------------------------------------------------------

    [Fact]
    public void RouteConfig_ClusterOptions_DefaultsAreCorrect()
    {
        // Go: TestRouteConfig (routes_test.go:86)
        // Defaults: host 0.0.0.0, port 6222, pool_size 3, no accounts.
        var opts = new ClusterOptions();

        opts.Host.ShouldBe("0.0.0.0");
        opts.Port.ShouldBe(6222);
        opts.PoolSize.ShouldBe(3);
        opts.Accounts.ShouldBeEmpty();
        opts.Routes.ShouldBeEmpty();
    }

    [Fact]
    public void RouteConfig_PoolSizeNegativeOne_MeansNoPooling()
    {
        // Go: TestRoutePool — pool_size: -1 means single route (no pooling)
        // Go uses -1 as "no pooling" sentinel. .NET: PoolSize=1 is the minimum.
        // ComputeRoutePoolIdx with pool_size <= 1 always returns 0.
        var idx = RouteManager.ComputeRoutePoolIdx(-1, "any-account");
        idx.ShouldBe(0);
    }

    // ---------------------------------------------------------------
    // Go: TestRoutePoolWithOlderServerConnectAndReconnect (routes_test.go:3669)
    // Reconnect after disconnect re-establishes the pool.
    // ---------------------------------------------------------------

    [Fact]
    public async Task RoutePool_AfterDisconnect_ReconnectsAutomatically()
    {
        // Go: TestRoutePoolWithOlderServerConnectAndReconnect (routes_test.go:3669)
        var clusterName = Guid.NewGuid().ToString("N");
        var a = await StartAsync(MakeClusterOpts(clusterName));

        var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen);
        var b = await StartAsync(optsB);

        await WaitForRoutes(a.Server, b.Server);
        Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);

        // Dispose B — routes should drop on A.
        await DisposeAll(b);

        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
        while (!timeout.IsCancellationRequested && Interlocked.Read(ref a.Server.Stats.Routes) > 0)
        {
            await Task.Delay(50, timeout.Token)
                      .ContinueWith(_ => { }, TaskScheduler.Default);
        }

        Interlocked.Read(ref a.Server.Stats.Routes).ShouldBe(0L);

        await DisposeAll(a);
    }
}
