using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging;
using NATS.Server.Configuration;
using NATS.Server.Protocol;
using NATS.Server.Subscriptions;

namespace NATS.Server.Routes;

public sealed class RouteManager : IAsyncDisposable
{
    private static readonly ConcurrentDictionary<string, RouteManager> Managers = new(StringComparer.Ordinal);
    private static readonly TimeSpan RouteConnectDelay = TimeSpan.FromMilliseconds(250);
    private static readonly TimeSpan RouteConnectMaxDelay = TimeSpan.FromSeconds(2);

    public const byte GossipDefault = 0;
    public const byte GossipDisabled = 1;
    public const byte GossipOverride = 2;
    public static readonly TimeSpan DefaultRouteMaxPingInterval = TimeSpan.FromMinutes(2);

    private readonly ClusterOptions _options;
    private readonly ServerStats _stats;
    private readonly string _serverId;
    private readonly ILogger<RouteManager> _logger;
    private readonly Action<RemoteSubscription> _remoteSubSink;
    private readonly Action<RouteMessage> _routedMessageSink;
    private readonly ConcurrentDictionary<string, RouteConnection> _routes = new(StringComparer.Ordinal);
    private readonly ConcurrentDictionary<string, RouteConnection> _accountRoutes = new(StringComparer.Ordinal);
    private readonly ConcurrentDictionary<string, byte> _connectedServerIds = new(StringComparer.Ordinal);
    private readonly ConcurrentDictionary<ulong, RouteConnection> _routesByHash = new();
    private readonly HashSet<string> _discoveredRoutes = new(StringComparer.OrdinalIgnoreCase);
    private readonly HashSet<string> _knownRouteUrls = new(StringComparer.OrdinalIgnoreCase);

    private CancellationTokenSource? _cts;
    private Socket? _listener;
    private Task? _acceptLoopTask;

    public string ListenEndpoint => $"{_options.Host}:{_options.Port}";

    /// <summary>
    /// The pool size configured in cluster options. Matches the Go server's
    /// default of 3 when not explicitly configured.
    /// Go reference: server/route.go opts.Cluster.PoolSize.
    /// </summary>
    public int ConfiguredPoolSize => _options.PoolSize > 0 ? _options.PoolSize : 3;

    /// <summary>
    /// Returns the negotiated pool size for the connected route identified by
    /// <paramref name="remoteServerId"/>, or the configured pool size if no
    /// such route connection exists.
    /// Go reference: server/route.go negotiateRoutePool.
    /// </summary>
    public int GetEffectivePoolSize(string? remoteServerId)
    {
        if (remoteServerId is { Length: > 0 })
        {
            foreach (var route in _routes.Values)
            {
                if (string.Equals(route.RemoteServerId, remoteServerId, StringComparison.Ordinal)
                    && route.NegotiatedPoolSize > 0)
                {
                    return route.NegotiatedPoolSize;
                }
            }
        }

        return ConfiguredPoolSize;
    }

    public RouteTopologySnapshot BuildTopologySnapshot()
    {
        return new RouteTopologySnapshot(
            _serverId,
            _routes.Count,
            _connectedServerIds.Keys.OrderBy(static k => k, StringComparer.Ordinal).ToArray());
    }

    public RouteManager(
        ClusterOptions options,
        ServerStats stats,
        string serverId,
        Action<RemoteSubscription> remoteSubSink,
        Action<RouteMessage> routedMessageSink,
        ILogger<RouteManager> logger)
    {
        _options = options;
        _stats = stats;
        _serverId = serverId;
        _remoteSubSink = remoteSubSink;
        _routedMessageSink = routedMessageSink;
        _logger = logger;
    }

    /// <summary>
    /// Routes auto-discovered via INFO gossip from peers.
    /// Go reference: server/route.go processImplicitRoute.
    /// </summary>
    public IReadOnlyCollection<string> DiscoveredRoutes
    {
        get { lock (_discoveredRoutes) return _discoveredRoutes.ToList(); }
    }

    /// <summary>
    /// Event raised when new route info should be forwarded to known peers.
    /// Go reference: server/route.go forwardNewRouteInfoToKnownServers.
    /// </summary>
    public event Action<List<string>>? OnForwardInfo;
    public event Action<string>? OnRouteRemoved;
    public event Action<string, string>? OnRouteAccountRemoved;

    /// <summary>
    /// Processes connect_urls from a peer's INFO message. Any URLs not already
    /// known are added to DiscoveredRoutes for solicited connection.
    /// Go reference: server/route.go:1500-1550 (processImplicitRoute).
    /// </summary>
    public void ProcessImplicitRoute(ServerInfo serverInfo)
    {
        if (serverInfo.ConnectUrls is null || serverInfo.ConnectUrls.Length == 0)
            return;

        lock (_discoveredRoutes)
        {
            foreach (var url in serverInfo.ConnectUrls)
            {
                if (HasThisRouteConfigured(url))
                    continue;

                var normalized = NormalizeRouteUrl(url);
                if (_discoveredRoutes.Any(existing =>
                        string.Equals(NormalizeRouteUrl(existing), normalized, StringComparison.OrdinalIgnoreCase)))
                {
                    continue;
                }

                _discoveredRoutes.Add(url);
            }
        }
    }

    /// <summary>
    /// Forwards new peer URL information to all known route connections.
    /// Go reference: server/route.go forwardNewRouteInfoToKnownServers.
    /// </summary>
    public void ForwardNewRouteInfoToKnownServers(string newPeerUrl)
    {
        OnForwardInfo?.Invoke([newPeerUrl]);
    }

    /// <summary>
    /// Adds a URL to the known route set. Used during initialization and testing.
    /// </summary>
    public void AddKnownRoute(string url)
    {
        lock (_discoveredRoutes)
        {
            _knownRouteUrls.Add(NormalizeRouteUrl(url));
        }
    }

    /// <summary>
    /// Returns true when the URL is already an explicit configured route or already
    /// known from startup/config processing.
    /// Go reference: server/route.go hasThisRouteConfigured.
    /// </summary>
    internal bool HasThisRouteConfigured(string routeUrl)
    {
        var normalized = NormalizeRouteUrl(routeUrl);
        lock (_discoveredRoutes)
        {
            if (_knownRouteUrls.Contains(normalized))
                return true;
        }

        return _options.Routes.Any(r => string.Equals(NormalizeRouteUrl(r), normalized, StringComparison.OrdinalIgnoreCase));
    }

    /// <summary>
    /// Returns true if the route URL is still valid for reconnect attempts.
    /// Go reference: server/route.go routeStillValid.
    /// </summary>
    internal bool RouteStillValid(string routeUrl)
    {
        var normalized = NormalizeRouteUrl(routeUrl);
        if (HasThisRouteConfigured(normalized))
            return true;

        lock (_discoveredRoutes)
        {
            return _discoveredRoutes.Any(existing =>
                string.Equals(NormalizeRouteUrl(existing), normalized, StringComparison.OrdinalIgnoreCase));
        }
    }

    /// <summary>
    /// Returns a route pool index for the given account name, matching Go's
    /// <c>computeRoutePoolIdx</c> (route.go:533-545). Uses FNV-1a 32-bit hash
    /// to deterministically map account names to pool indices.
    /// </summary>
    public static int ComputeRoutePoolIdx(int poolSize, string accountName)
    {
        if (poolSize <= 1)
            return 0;

        var bytes = System.Text.Encoding.UTF8.GetBytes(accountName);

        // Use FNV-1a to match Go exactly
        uint fnvHash = 2166136261; // FNV offset basis
        foreach (var b in bytes)
        {
            fnvHash ^= b;
            fnvHash *= 16777619; // FNV prime
        }

        return (int)(fnvHash % (uint)poolSize);
    }

    /// <summary>
    /// Returns the route connection responsible for the given account. Checks
    /// dedicated account routes first, then falls back to pool-based selection,
    /// and finally falls back to the first legacy (pre-pool) route.
    /// Go reference: server/route.go — per-account dedicated route lookup,
    /// getRoutesExcludePool (legacy fallback).
    /// </summary>
    public RouteConnection? GetRouteForAccount(string account)
    {
        // 1st: Check dedicated account routes (Gap 13.2).
        if (_accountRoutes.TryGetValue(account, out var dedicated))
            return dedicated;

        if (_routes.IsEmpty)
            return null;

        // 2nd: Try pool-based routes (current behavior).
        var poolRoutes = _routes.Values.Where(r => r.SupportsPooling).ToArray();
        if (poolRoutes.Length > 0)
        {
            var idx = ComputeRoutePoolIdx(poolRoutes.Length, account);
            return poolRoutes[idx % poolRoutes.Length];
        }

        // 3rd: Fall back to legacy route when no pool routes exist (Gap 13.6).
        return GetLegacyRoute();
    }

    /// <summary>
    /// Returns the first route connection with <see cref="RouteConnection.IsLegacyRoute"/>
    /// equal to <c>true</c>, or <c>null</c> if no legacy routes are registered.
    /// Legacy routes are connections to pre-pool peers that did not negotiate a
    /// pool size during the handshake.
    /// Go reference: server/route.go getRoutesExcludePool.
    /// </summary>
    public RouteConnection? GetLegacyRoute()
        => _routes.Values.FirstOrDefault(r => r.IsLegacyRoute);

    /// <summary>
    /// Returns all route connections with <see cref="RouteConnection.IsLegacyRoute"/>
    /// equal to <c>true</c>. Returns an empty list when no legacy routes exist.
    /// Go reference: server/route.go getRoutesExcludePool.
    /// </summary>
    public IReadOnlyList<RouteConnection> GetLegacyRoutes()
        => _routes.Values.Where(r => r.IsLegacyRoute).ToList();

    /// <summary>
    /// Returns <c>true</c> when at least one registered route connection is a
    /// legacy (pre-pool) route.
    /// Go reference: server/route.go getRoutesExcludePool.
    /// </summary>
    public bool HasLegacyRoutes
        => _routes.Values.Any(r => r.IsLegacyRoute);

    /// <summary>
    /// Registers a dedicated route connection for a specific account. If a
    /// previous connection was registered for the same account it is replaced.
    /// Go reference: server/route.go — per-account dedicated route registration.
    /// </summary>
    public void RegisterAccountRoute(string account, RouteConnection connection)
    {
        _accountRoutes[account] = connection;
    }

    /// <summary>
    /// Removes the dedicated route for the given account. If no dedicated route
    /// was registered this is a no-op.
    /// </summary>
    public void UnregisterAccountRoute(string account)
    {
        if (!_accountRoutes.TryRemove(account, out var route))
            return;

        if (route.RemoteServerId is { Length: > 0 } remoteServerId)
            OnRouteAccountRemoved?.Invoke(remoteServerId, account);
    }

    /// <summary>
    /// Returns the dedicated route connection for the given account, or null if
    /// no dedicated route has been registered.
    /// </summary>
    public RouteConnection? GetDedicatedAccountRoute(string account)
        => _accountRoutes.TryGetValue(account, out var connection) ? connection : null;

    /// <summary>
    /// Returns true when a dedicated route is registered for the given account.
    /// </summary>
    public bool HasDedicatedRoute(string account)
        => _accountRoutes.ContainsKey(account);

    /// <summary>
    /// Returns the set of account names that currently have a dedicated route
    /// registered.
    /// </summary>
    public IReadOnlyCollection<string> GetAccountsWithDedicatedRoutes()
        => (IReadOnlyCollection<string>)_accountRoutes.Keys;

    /// <summary>
    /// The number of dedicated per-account routes currently registered.
    /// </summary>
    public int DedicatedRouteCount => _accountRoutes.Count;

    // -------------------------------------------------------------------------
    // Hash-based O(1) route storage (Gap 13.4)
    // Go reference: server/route.go — server-ID-keyed route hash map.
    // -------------------------------------------------------------------------

    /// <summary>
    /// Computes a 64-bit FNV-1a hash of the given server ID.  Used as the key
    /// for O(1) route lookup by hash. Identical algorithm to the 32-bit pool
    /// index variant but uses 64-bit constants for a wider key space.
    /// Go reference: server/route.go — route hash key derivation.
    /// </summary>
    public static ulong ComputeRouteHash(string serverId)
    {
        const ulong fnvOffset = 14695981039346656037UL;
        const ulong fnvPrime = 1099511628211UL;
        var hash = fnvOffset;
        foreach (var b in System.Text.Encoding.UTF8.GetBytes(serverId))
        {
            hash ^= b;
            hash *= fnvPrime;
        }
        return hash;
    }

    /// <summary>
    /// Stores <paramref name="connection"/> in the hash map keyed by the
    /// FNV-1a hash of <paramref name="serverId"/>. An existing entry for the
    /// same server ID is overwritten.
    /// Go reference: server/route.go — route hash registration.
    /// </summary>
    public void RegisterRouteByHash(string serverId, RouteConnection connection)
    {
        var hash = ComputeRouteHash(serverId);
        _routesByHash[hash] = connection;
    }

    /// <summary>
    /// Removes the entry keyed by the FNV-1a hash of <paramref name="serverId"/>.
    /// If no entry exists this is a no-op.
    /// Go reference: server/route.go — route hash deregistration.
    /// </summary>
    public void UnregisterRouteByHash(string serverId)
    {
        var hash = ComputeRouteHash(serverId);
        _routesByHash.TryRemove(hash, out _);
    }

    /// <summary>
    /// Returns the route connection associated with <paramref name="hash"/>,
    /// or <c>null</c> if no entry exists. O(1) lookup.
    /// Go reference: server/route.go — O(1) route lookup by hash.
    /// </summary>
    public RouteConnection? GetRouteByHash(ulong hash)
        => _routesByHash.TryGetValue(hash, out var connection) ? connection : null;

    /// <summary>
    /// Convenience overload: computes the hash from <paramref name="serverId"/>
    /// and returns the associated route connection, or <c>null</c>.
    /// Go reference: server/route.go — server-ID-keyed route lookup.
    /// </summary>
    public RouteConnection? GetRouteByServerId(string serverId)
        => GetRouteByHash(ComputeRouteHash(serverId));

    /// <summary>
    /// The number of entries in the hash-keyed route map.
    /// </summary>
    public int HashedRouteCount => _routesByHash.Count;

    public Task StartAsync(CancellationToken ct)
    {
        _cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        Managers[_serverId] = this;
        _listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        _listener.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
        _listener.Bind(new IPEndPoint(IPAddress.Parse(_options.Host), _options.Port));
        _listener.Listen(128);

        if (_options.Port == 0)
            _options.Port = ((IPEndPoint)_listener.LocalEndPoint!).Port;

        _acceptLoopTask = Task.Run(() => AcceptLoopAsync(_cts.Token));
        var poolSize = Math.Max(_options.PoolSize, 1);
        foreach (var route in _options.Routes.Distinct(StringComparer.OrdinalIgnoreCase))
        {
            AddKnownRoute(route);
            for (var i = 0; i < poolSize; i++)
            {
                var poolIndex = i;
                _ = Task.Run(() => ConnectToRouteWithRetryAsync(route, poolIndex, _cts.Token));
            }
        }

        return Task.CompletedTask;
    }

    public async ValueTask DisposeAsync()
    {
        if (_cts == null)
            return;

        await _cts.CancelAsync();
        _listener?.Dispose();

        if (_acceptLoopTask != null)
            await _acceptLoopTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);

        foreach (var route in _routes.Values)
            await route.DisposeAsync();

        _routes.Clear();
        _connectedServerIds.Clear();
        Managers.TryRemove(_serverId, out _);
        Interlocked.Exchange(ref _stats.Routes, 0);
        _cts.Dispose();
        _cts = null;
    }

    public void PropagateLocalSubscription(string account, string subject, string? queue)
    {
        if (_routes.IsEmpty)
            return;

        foreach (var route in _routes.Values)
        {
            _ = route.SendRsPlusAsync(account, subject, queue, _cts?.Token ?? CancellationToken.None);
        }
    }

    public void PropagateLocalUnsubscription(string account, string subject, string? queue)
    {
        if (_routes.IsEmpty)
            return;

        foreach (var route in _routes.Values)
            _ = route.SendRsMinusAsync(account, subject, queue, _cts?.Token ?? CancellationToken.None);
    }

    public async Task ForwardRoutedMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
    {
        if (_routes.IsEmpty)
            return;

        // Use account-based pool routing: route the message only through the
        // connection responsible for this account, matching Go's behavior.
        var route = GetRouteForAccount(account);
        if (route != null)
        {
            await route.SendRmsgAsync(account, subject, replyTo, payload, ct);
            return;
        }

        // Fallback: broadcast to all routes if pool routing fails
        foreach (var r in _routes.Values)
            await r.SendRmsgAsync(account, subject, replyTo, payload, ct);
    }

    private async Task AcceptLoopAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            Socket socket;
            try
            {
                socket = await _listener!.AcceptAsync(ct);
            }
            catch (OperationCanceledException)
            {
                break;
            }
            catch (ObjectDisposedException)
            {
                break;
            }
            catch (Exception ex)
            {
                _logger.LogDebug(ex, "Route accept loop error");
                break;
            }

            _ = Task.Run(() => HandleInboundRouteAsync(socket, ct), ct);
        }
    }

    private async Task HandleInboundRouteAsync(Socket socket, CancellationToken ct)
    {
        var route = new RouteConnection(socket);
        try
        {
            await route.PerformInboundHandshakeAsync(_serverId, ct);
            Register(route);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Inbound route handshake failed");
            await route.DisposeAsync();
        }
    }

    private async Task ConnectToRouteWithRetryAsync(string route, int poolIndex, CancellationToken ct)
    {
        var attempt = 0;
        while (!ct.IsCancellationRequested)
        {
            if (!RouteStillValid(route))
                return;

            try
            {
                var endPoint = ParseRouteEndpoint(route);
                var socket = CreateRouteDialSocket();
                await socket.ConnectAsync(endPoint.Address, endPoint.Port, ct);
                var connection = new RouteConnection(socket) { PoolIndex = poolIndex, IsSolicited = true };
                await connection.PerformOutboundHandshakeAsync(_serverId, ct);
                Register(connection);
                return;
            }
            catch (OperationCanceledException)
            {
                return;
            }
            catch (Exception ex)
            {
                _logger.LogDebug(ex, "Failed to connect route seed {Route}", route);
            }

            try
            {
                attempt++;
                await Task.Delay(ComputeRetryDelay(attempt), ct);
            }
            catch (OperationCanceledException)
            {
                return;
            }
        }
    }

    internal static Socket CreateRouteDialSocket()
    {
        var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        // Go natsDialTimeout sets KeepAlive to -1 (disabled) on outbound route dials.
        socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, false);
        return socket;
    }

    private void Register(RouteConnection route)
    {
        var key = $"{route.RemoteServerId}:{route.RemoteEndpoint}:{Guid.NewGuid():N}";
        if (!_routes.TryAdd(key, route))
        {
            _ = route.DisposeAsync();
            return;
        }

        if (route.RemoteServerId is { Length: > 0 } remoteServerId)
            _connectedServerIds[remoteServerId] = 0;

        route.RemoteSubscriptionReceived = sub =>
        {
            _remoteSubSink(sub);
            return Task.CompletedTask;
        };
        route.RoutedMessageReceived = msg =>
        {
            _routedMessageSink(msg);
            return Task.CompletedTask;
        };
        route.StartFrameLoop(_cts!.Token);

        Interlocked.Increment(ref _stats.Routes);
        _ = Task.Run(() => WatchRouteAsync(key, route, _cts!.Token));
    }

    private async Task WatchRouteAsync(string key, RouteConnection route, CancellationToken ct)
    {
        try
        {
            await route.WaitUntilClosedAsync(ct);
        }
        catch (OperationCanceledException)
        {
            // Shutdown path.
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Route {RouteKey} closed with error", key);
        }
        finally
        {
            if (_routes.TryRemove(key, out _))
            {
                Interlocked.Decrement(ref _stats.Routes);
                if (route.RemoteServerId is { Length: > 0 } remoteServerId && !HasConnectedRouteForServerId(remoteServerId))
                    OnRouteRemoved?.Invoke(remoteServerId);
            }

            await route.DisposeAsync();
        }
    }

    private bool HasConnectedRouteForServerId(string remoteServerId)
    {
        var prefix = remoteServerId + ":";
        return _routes.Any(kvp =>
            kvp.Key.StartsWith(prefix, StringComparison.Ordinal)
            || string.Equals(kvp.Value.RemoteServerId, remoteServerId, StringComparison.Ordinal));
    }

    private static IPEndPoint ParseRouteEndpoint(string route)
    {
        var trimmed = NormalizeRouteUrl(route);
        var parts = trimmed.Split(':', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
        if (parts.Length != 2)
            throw new FormatException($"Invalid route endpoint: '{route}'");

        return new IPEndPoint(IPAddress.Parse(parts[0]), int.Parse(parts[1]));
    }

    public int RouteCount => _routes.Count;

    /// <summary>
    /// Adds a route directly to both internal dictionaries. Used for testing
    /// and for programmatic registration of routes by server ID.
    /// Go reference: server/route.go addRoute (internal registration path).
    /// </summary>
    internal void RegisterRoute(string serverId, RouteConnection connection)
    {
        var key = $"{serverId}:{Guid.NewGuid():N}";
        _routes[key] = connection;
        _connectedServerIds[serverId] = 0;
    }

    /// <summary>
    /// Removes the route associated with the given server ID from both the
    /// route pool and the connected-server-ID set.
    /// Returns true if a route was found and removed, false otherwise.
    /// Go reference: server/route.go removeRoute (lines 3113+).
    /// </summary>
    public bool RemoveRoute(string serverId)
    {
        var found = false;
        var prefix = serverId + ":";
        var removedRoutes = new List<RouteConnection>();

        foreach (var key in _routes.Keys.ToArray())
        {
            if (!key.StartsWith(prefix, StringComparison.Ordinal))
                continue;

            if (_routes.TryRemove(key, out var removed))
            {
                found = true;
                removedRoutes.Add(removed);
                Interlocked.Decrement(ref _stats.Routes);
                _ = removed.DisposeAsync();
            }
        }

        if (found)
        {
            _connectedServerIds.TryRemove(serverId, out _);
            UnregisterRouteByHash(serverId);

            foreach (var kvp in _accountRoutes.ToArray())
            {
                if (removedRoutes.Contains(kvp.Value)
                    || string.Equals(kvp.Value.RemoteServerId, serverId, StringComparison.Ordinal))
                {
                    _accountRoutes.TryRemove(kvp.Key, out _);
                }
            }

            OnRouteRemoved?.Invoke(serverId);
        }

        return found;
    }

    /// <summary>
    /// Removes all routes whose server ID is not in <paramref name="keepServerIds"/>.
    /// Returns the number of routes removed.
    /// Go reference: server/route.go removeAllRoutesExcept (lines 3085-3111).
    /// </summary>
    public int RemoveAllRoutesExcept(IReadOnlySet<string> keepServerIds)
    {
        var removed = 0;

        foreach (var key in _routes.Keys.ToArray())
        {
            var colonIdx = key.IndexOf(':', StringComparison.Ordinal);
            var keyServerId = colonIdx >= 0 ? key[..colonIdx] : key;

            if (!keepServerIds.Contains(keyServerId))
            {
                if (_routes.TryRemove(key, out _))
                {
                    _connectedServerIds.TryRemove(keyServerId, out _);
                    removed++;
                }
            }
        }

        return removed;
    }

    /// <summary>
    /// Compares the currently-connected server IDs against the expected peer set
    /// and returns which peers are missing and which are unexpected.
    /// <see cref="ClusterSplitResult.IsSplit"/> is true when any expected peers
    /// are absent from the connected set.
    /// Go reference: server/route.go — cluster split / network partition detection.
    /// </summary>
    public ClusterSplitResult DetectClusterSplit(IReadOnlySet<string> expectedPeers)
    {
        var connected = _connectedServerIds.Keys.ToHashSet(StringComparer.Ordinal);

        var missing = expectedPeers
            .Where(p => !connected.Contains(p))
            .OrderBy(static p => p, StringComparer.Ordinal)
            .ToList();

        var unexpected = connected
            .Where(c => !expectedPeers.Contains(c))
            .OrderBy(static c => c, StringComparer.Ordinal)
            .ToList();

        return new ClusterSplitResult(missing, unexpected, missing.Count > 0);
    }

    internal bool HasSolicitedRoute(string remoteServerId)
    {
        var prefix = remoteServerId + ":";
        return _routes.Any(kvp =>
            kvp.Value.IsSolicited &&
            (kvp.Key.StartsWith(prefix, StringComparison.Ordinal)
                || string.Equals(kvp.Value.RemoteServerId, remoteServerId, StringComparison.Ordinal)));
    }

    internal bool UpgradeRouteToSolicited(string remoteServerId)
    {
        var prefix = remoteServerId + ":";
        foreach (var kvp in _routes)
        {
            var route = kvp.Value;
            var matches = kvp.Key.StartsWith(prefix, StringComparison.Ordinal)
                || string.Equals(route.RemoteServerId, remoteServerId, StringComparison.Ordinal);
            if (!matches)
                continue;

            route.IsSolicited = true;
            return true;
        }

        return false;
    }

    internal bool IsDuplicateServerName(string remoteServerId)
        => _connectedServerIds.ContainsKey(remoteServerId);

    private static TimeSpan ComputeRetryDelay(int attempt)
    {
        var factor = Math.Pow(2, Math.Clamp(attempt, 0, 8));
        var delayMs = RouteConnectDelay.TotalMilliseconds * factor;
        var boundedMs = Math.Min(delayMs, RouteConnectMaxDelay.TotalMilliseconds);
        return TimeSpan.FromMilliseconds(boundedMs);
    }

    private static string NormalizeRouteUrl(string routeUrl)
    {
        var value = routeUrl.Trim();
        if (value.StartsWith("nats-route://", StringComparison.OrdinalIgnoreCase))
            value = value["nats-route://".Length..];
        else if (value.StartsWith("nats://", StringComparison.OrdinalIgnoreCase))
            value = value["nats://".Length..];

        if (Uri.TryCreate($"nats://{value}", UriKind.Absolute, out var uri))
            return $"{uri.Host}:{uri.Port}";

        return value;
    }
}

public sealed record RouteTopologySnapshot(
    string ServerId,
    int RouteCount,
    IReadOnlyList<string> ConnectedServerIds);

/// <summary>
/// Describes the outcome of comparing a <see cref="RouteManager"/>'s current
/// connected peers against the expected full peer set.
/// <see cref="IsSplit"/> is true when at least one expected peer is not connected,
/// indicating a network partition or cluster split.
/// Go reference: server/route.go — partition detection logic.
/// </summary>
public sealed record ClusterSplitResult(
    IReadOnlyList<string> MissingPeers,
    IReadOnlyList<string> UnexpectedPeers,
    bool IsSplit);
