using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using common;
using common.UpdateNotification;
using Grpc.Core;
using Net.Eagle0.Eagle.Api;
using static Net.Eagle0.Eagle.Api.UpdateStreamRequest.Types;
using GameUpdate = Net.Eagle0.Eagle.Api.GameUpdate;
using Timer = System.Timers.Timer;

namespace eagle {
    using EagleGameId = Int64;
    using ShardokGameId = String;
    using ProvinceId = Int32;

    public enum ConnectionState {
        Disconnected,
        Connecting,
        Connected,
        Reconnecting,
        SubscriptionPending,  // Connected but waiting for subscription acknowledgment
        RefreshingAuthentication,
        AuthenticationRequired
    }

    public interface ILobbySubscriber {
        void ReceiveLobbyUpdate(LobbyResponse lobbyResponse);
        void HandleJoinGameResponse(JoinGameResponse response);
        void HandleCreateGameResponse(CreateGameResponse response);
        void HandleCreateGameAcceptedResponse(CreateGameAcceptedResponse response);
        void HandleCustomBattleResponse(CustomBattleResponse response);
    }

    public class PersistentClientConnection : IDisposable {
        /// <summary>
        /// The current active connection. Set by ConnectionHandler when creating the connection.
        /// </summary>
        public static PersistentClientConnection Current { get; set; }

        private const double TimeoutSeconds = 300.0;
        // Safety-net idle timeout; catches dead connections missed by HTTP/2 keepalive
        private const double MaxIdleSeconds = 30.0;

        private DateTime _lastResponseReceived = DateTime.UtcNow;
        private int _lastIdleWarningLevel = 0;  // 0=none, 1=10s warning, 2=20s warning
        private readonly Metadata _credentials;
        private readonly CancellationToken _cancellationToken;
        private readonly Func<Task<bool>> _tryRefreshAuthenticationAsync;
        private readonly string _clientId = Guid.NewGuid().ToString();

        private readonly Logger _remoteEagleClientLogger = Logger.GetLogger("ConnectionLogger");
        private readonly Logger _timingsLogger = Logger.GetLogger("ConnectionLogger");

        // Counter to diagnose duplicate logging - if logs show same seq# twice, two threads
        // are processing same message. If seq# increments but lines doubled, Logger issue.
        private int _logFlowSeq = 0;

        /// <summary>Timestamped log for connection flow tracing.</summary>
        private void LogFlow(string message) {
            var seq = Interlocked.Increment(ref _logFlowSeq);
            var ts = DateTime.UtcNow.ToString("HH:mm:ss.fff");
            _remoteEagleClientLogger.LogLine($"[FLOW {ts}] #{seq} {message}");
        }

        private volatile bool _isConnecting = false;
        private volatile bool _isExpectedReconnect = false;
        private volatile bool _connectAgainRequested = false;
        private volatile bool _authenticationRefreshInProgress = false;
        private volatile bool _disposed = false;
        private Timer _idleCheckTimer = null;
        private Timer _heartbeatTimer = null;
        private const double HeartbeatIntervalSeconds = 10.0;

        // Per-command response timeout: if no PostCommandResponse arrives within this
        // period after posting, proactively reconnect rather than waiting for heartbeat
        // to detect the sync mismatch.
        private const double CommandResponseTimeoutSeconds = 10.0;
        private DateTime _lastCommandPostedAt;
        private volatile bool _awaitingCommandResponse = false;

        // Connection metrics for diagnostics
        private DateTime? _lastConnectAttempt = null;
        private DateTime? _lastSuccessfulConnect = null;
        private DateTime? _lastDisconnect = null;
        private StatusCode? _lastDisconnectReason = null;

        // Exponential backoff for reconnection
        private int _consecutiveFailures = 0;
        private const double MinBackoffSeconds = 2.0;
        private const double MaxBackoffSeconds = 32.0;

        // Connection state for UI monitoring
        private ConnectionState _currentState = ConnectionState.Disconnected;
        public ConnectionState CurrentState => _currentState;
        public DateTime? NextReconnectAttempt { get; private set; } = null;

        // Circuit breaker for preventing cascading failures
        private ConnectionCircuitBreaker _circuitBreaker = new ConnectionCircuitBreaker();
        public ConnectionCircuitBreaker CircuitBreaker => _circuitBreaker;

        /// <summary>
        /// Force an immediate reconnection attempt, bypassing the circuit breaker timeout.
        /// </summary>
        public void ForceReconnect() {
            lock (this) {
                if (_disposed) {
                    LogFlow("ForceReconnect() SKIPPED disposed=true");
                    return;
                }

                _circuitBreaker.ForceReconnect();
                _retryTimer?.Dispose();
                _retryTimer = null;
                NextReconnectAttempt = null;
            }

            LogConnectionEvent("force_reconnect", "User requested immediate reconnect");
            RunConnectAsync();
        }

        /// <summary>
        /// Fire-and-forget wrapper for Connect() that ensures exceptions are logged
        /// rather than silently swallowed by Task.Run().
        /// </summary>
        private void RunConnectAsync() {
            if (_disposed) return;

            _ = Task.Run(async () => {
                try {
                    if (_disposed) return;
                    await Connect();
                } catch (Exception e) {
                    // Log but don't rethrow - this is fire-and-forget
                    Console.WriteLine($"[CONNECT] Exception in Connect: {e}");
                    _remoteEagleClientLogger.LogLine($"Exception in Connect: {e}");
                }
            });
        }

        private DateTime? GetDeadlineFromNow() {
            return DateTime.UtcNow.AddSeconds(TimeoutSeconds);
        }

        private int GetTotalShardokGames() {
            int total = 0;
            lock (this) {
                foreach (var sub in _subscribers.Values) { total += sub.ShardokViewStatuses.Count; }
            }
            return total;
        }

        private void
        LogConnectionEvent(string eventType, string details = null, StatusCode? statusCode = null) {
            var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff");
            var shardokCount = GetTotalShardokGames();
            var shardokInfo = shardokCount > 0 ? $" shardok_games={shardokCount}" : "";
            var statusInfo = statusCode.HasValue ? $" status={statusCode.Value}" : "";
            var detailsInfo = !string.IsNullOrEmpty(details) ? $" details=\"{details}\"" : "";
            var timeSinceLastConnect =
                    _lastSuccessfulConnect.HasValue
                            ? $" seconds_since_connect={(DateTime.UtcNow - _lastSuccessfulConnect.Value).TotalSeconds:F1}"
                            : "";

            _remoteEagleClientLogger.LogLine(
                    $"[CONNECTION] timestamp={timestamp} event={eventType}{shardokInfo}{statusInfo}{detailsInfo}{timeSinceLastConnect}");
        }

        private double GetBackoffSeconds() {
            if (_consecutiveFailures == 0) { return MinBackoffSeconds; }

            // Exponential backoff: 2, 4, 8, 16, 32 (capped)
            double backoff = MinBackoffSeconds * Math.Pow(2, _consecutiveFailures);
            return Math.Min(backoff, MaxBackoffSeconds);
        }

        private void ScheduleReconnect(string reason) {
            lock (this) {
                if (_disposed) {
                    LogFlow($"SCHEDULE_RECONNECT skipped reason={reason} disposed=true");
                    return;
                }

                double backoffSeconds = GetBackoffSeconds();
                _consecutiveFailures++;
                // Stay quiet during the first retry after a disconnect. Occasional
                // disconnects are normal and usually recover on the first attempt, so we
                // suppress the "Reconnecting..." UX until a retry has itself failed.
                if (_consecutiveFailures > 1) { _currentState = ConnectionState.Reconnecting; }
                NextReconnectAttempt = DateTime.UtcNow.AddSeconds(backoffSeconds);

                LogFlow($"SCHEDULE_RECONNECT reason={reason} backoff={backoffSeconds:F1}s attempt={_consecutiveFailures}");
                LogConnectionEvent(
                        "schedule_reconnect",
                        $"{reason}, backoff={backoffSeconds:F1}s, attempt={_consecutiveFailures}");

                _retryTimer?.Dispose();
                _retryTimer = new Timer { AutoReset = false, Interval = backoffSeconds * 1000 };
                _retryTimer.Elapsed += (sender, args) => {
                    if (!_disposed) { RunConnectAsync(); }
                };
                _retryTimer.Enabled = true;
            }
        }

        private readonly Eagle.EagleClient _grpcClient;
        private AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> _streamingCall;

        private Dictionary<EagleGameId, IClientConnectionSubscriber> _subscribers = new();
        private ILobbySubscriber _lobbySubscriber = null;
        private Thread _streamingCallThread;
        private CancellationTokenSource _threadCancellationTokenSource;
        private CancellationToken _currentThreadToken;

        private List<PostCommandRequest> _pendingCommands = new();

        // Serializes TryPendingCommands invocations. Multiple MainQueue actions (one per
        // ActionResultResponse / ShardokActionResultResponse) each call TryPendingCommands,
        // and they're enqueued as `async void` lambdas — when one yields at an await, the
        // MainQueue's per-frame Update loop immediately invokes the next action, which
        // re-enters TryPendingCommands. Without this guard, each invocation re-drains
        // _pendingCommands (which PostRequest synchronously re-adds to before yielding),
        // causing the same buffered command to be written to the wire many times in a
        // single frame and producing bursts of BAD_TOKEN responses from the server.
        private readonly SemaphoreSlim _tryPendingCommandsSemaphore = new(1, 1);

        /// <summary>
        /// Checks if there's a pending Eagle command with the specified token for the given game.
        /// Used by GameModelUpdater to skip loading stale commands when a pending command
        /// is about to be posted.
        /// </summary>
        public bool HasPendingEagleCommandWithToken(long gameId, long token) {
            lock (this) {
                foreach (var cmd in _pendingCommands) {
                    if (cmd.GameId == gameId &&
                        cmd.Command.SealedValueCase ==
                                UniversalCommand.SealedValueOneofCase.EagleCommand &&
                        cmd.Command.EagleCommand.EagleToken == token) {
                        return true;
                    }
                }

                return false;
            }
        }

        /// <summary>
        /// Callback invoked when the underlying gRPC channel is disposed and cannot be recovered.
        /// The caller should create a new EagleConnection and PersistentClientConnection.
        /// </summary>
        public Action OnChannelDead { get; set; }

        /// <summary>
        /// Callback invoked when a command is rejected by the server with an error.
        /// The string parameter contains the error message from the server.
        /// </summary>
        public Action<string> OnCommandError { get; set; }

        // Pending subscription acknowledgments - used to wait for server confirmation
        private readonly Dictionary<EagleGameId, TaskCompletionSource<SubscriptionAck>>
                _pendingSubscriptionAcks = new();
        private const int SubscriptionAckTimeoutMs = 10000;  // 10 seconds
        private const int WriteAsyncTimeoutMs = 10000;       // 10 seconds for WriteAsync operations

        public PersistentClientConnection(
                Eagle.EagleClient grpcClient,
                Metadata credentials,
                CancellationToken cancellationToken,
                Func<Task<bool>> tryRefreshAuthenticationAsync = null) {
            _grpcClient = grpcClient;
            _credentials = credentials;
            _cancellationToken = cancellationToken;
            _tryRefreshAuthenticationAsync = tryRefreshAuthenticationAsync;
        }

        /// <summary>
        /// Sends a subscription request for a game and awaits server acknowledgment.
        /// Returns true if the subscription was acknowledged successfully, false otherwise.
        /// IMPORTANT: Must be called outside of lock(this) to allow awaiting.
        /// </summary>
        private async Task<bool> StreamOneGameAsync(IClientConnectionSubscriber subscriber) {
            var gameId = subscriber.GameId;
            var streamGameRequest = new StreamGameRequest {
                GameId = gameId,
                UnfilteredResultCount = subscriber.LastUnfilteredResultCount,
                ClientId = _clientId
            };

            // Get Shardok statuses (may include resync flags)
            var shardokStatuses = subscriber.ShardokViewStatuses;
            streamGameRequest.ShardokViewStatuses.AddRange(shardokStatuses.Select(
                    status => new StreamGameRequest.Types.ShardokViewStatus {
                        FilteredResultCount = status.filteredResultCount,
                        ShardokGameId = status.shardokGameId,
                        RequestFullResync = status.requestFullResync
                    }));

            streamGameRequest.StreamingTextStatuses.AddRange(
                    subscriber.StreamingTextStatuses.Select(
                            status => new StreamGameRequest.Types.StreamingTextStatus {
                                LlmIdentifier = status.llmIdentifier,
                                KnownByteCount = status.knownByteCount,
                                KnownComplete = status.knownComplete,
                                StreamAttemptId = status.streamAttemptId
                            }));

            var request = new UpdateStreamRequest { StreamGameRequest = streamGameRequest };

            // Create a TaskCompletionSource to wait for the server's acknowledgment
            var ackTcs = new TaskCompletionSource<SubscriptionAck>();
            lock (_pendingSubscriptionAcks) { _pendingSubscriptionAcks[gameId] = ackTcs; }

            var shardokCount = shardokStatuses.Count();
            var streamingTextStatuses = streamGameRequest.StreamingTextStatuses;
            var completeTextCount = streamingTextStatuses.Count(status => status.KnownComplete);
            var incompleteTextCount = streamingTextStatuses.Count - completeTextCount;
            LogFlow($"SUBSCRIBE game={gameId} unfilteredCount={subscriber.LastUnfilteredResultCount} shardokGames={shardokCount}");
            _remoteEagleClientLogger.LogLine(
                    $"[SUBSCRIBE] Sending StreamGameRequest for game {gameId}, " +
                    $"unfilteredCount={subscriber.LastUnfilteredResultCount}, " +
                    $"shardokGames={shardokCount}, completeTexts={completeTextCount}, " +
                    $"incompleteTexts={incompleteTextCount}");

            var sendSuccess = await DoWithStreamingCall(async (sc) => {
                await sc.RequestStream.WriteAsync(request);
                _remoteEagleClientLogger.LogLine(
                        $"[SUBSCRIBE] StreamGameRequest sent successfully for game {gameId}");
                return true;
            });

            if (!sendSuccess) {
                // Write failed - clean up and return
                lock (_pendingSubscriptionAcks) { _pendingSubscriptionAcks.Remove(gameId); }
                LogConnectionEvent("subscribe_send_failed", $"game={gameId}");
                return false;
            }

            // Wait for server acknowledgment with timeout
            using var timeoutCts = new CancellationTokenSource();
            try {
                var ackTask = ackTcs.Task;
                var timeoutTask = Task.Delay(SubscriptionAckTimeoutMs, timeoutCts.Token);
                var completedTask = await Task.WhenAny(ackTask, timeoutTask);

                if (completedTask == timeoutTask) {
                    // Timeout waiting for ack
                    lock (_pendingSubscriptionAcks) { _pendingSubscriptionAcks.Remove(gameId); }
                    LogConnectionEvent("subscribe_ack_timeout", $"game={gameId}");
                    _remoteEagleClientLogger.LogLine(
                            $"[SUBSCRIBE] Timeout waiting for SubscriptionAck for game {gameId}");
                    return false;
                }

                // Cancel the timeout task since ack was received
                timeoutCts.Cancel();

                var ack = await ackTask;
                if (ack.Success) {
                    LogFlow($"SUBSCRIBE_ACK game={gameId} success=true confirmedCount={ack.ConfirmedResultCount}");
                    _remoteEagleClientLogger.LogLine(
                            $"[SUBSCRIBE] Subscription confirmed for game {gameId}, " +
                            $"confirmedResultCount={ack.ConfirmedResultCount}");

                    // Note: Shardok resync flags are cleared in EagleGameModel.HandleOneGameUpdate
                    // AFTER updates are actually received, not here. This ensures that if the
                    // connection drops between acknowledgment and update delivery, the resync
                    // will be requested again on the next reconnect.

                    return true;
                } else {
                    LogFlow($"SUBSCRIBE_ACK game={gameId} success=false error={ack.ErrorMessage}");
                    LogConnectionEvent(
                            "subscribe_ack_failed",
                            $"game={gameId}, error={ack.ErrorMessage}");
                    _remoteEagleClientLogger.LogLine(
                            $"[SUBSCRIBE] Server rejected subscription for game {gameId}: {ack.ErrorMessage}");
                    return false;
                }
            } catch (OperationCanceledException) {
                // Task was cancelled (e.g., connection drop) - not an error
                lock (_pendingSubscriptionAcks) { _pendingSubscriptionAcks.Remove(gameId); }
                _remoteEagleClientLogger.LogLine(
                        $"[SUBSCRIBE] Cancelled waiting for ack for game {gameId}");
                return false;
            } catch (Exception e) {
                lock (_pendingSubscriptionAcks) { _pendingSubscriptionAcks.Remove(gameId); }
                _remoteEagleClientLogger.LogLine(
                        $"[SUBSCRIBE] Exception waiting for ack for game {gameId}: {e.Message}");
                return false;
            }
        }

        /// <summary>
        /// Remove all pending commands for a specific game.
        /// Called when the server confirms a command was processed (SUCCESS or BAD_TOKEN).
        /// </summary>
        private void RemovePendingCommandsForGame(long gameId) {
            lock (this) {
                var toRemove = _pendingCommands.Where(cmd => cmd.GameId == gameId).ToList();
                foreach (var cmd in toRemove) { _pendingCommands.Remove(cmd); }
                if (toRemove.Count > 0) {
                    _remoteEagleClientLogger.LogLine(
                            $"[POST] Removed {toRemove.Count} pending command(s) for game {gameId}");
                }

                _awaitingCommandResponse = false;
            }
        }

        /// <summary>
        /// Refresh the subscription for a specific game to get fresh state from the server.
        /// Used when a command is rejected (e.g., BAD_TOKEN) and we need current state.
        /// </summary>
        private void RefreshGameSubscription(long gameId) {
            IClientConnectionSubscriber subscriber;
            lock (this) { _subscribers.TryGetValue(gameId, out subscriber); }
            if (subscriber != null) {
                _ = StreamOneGameAsync(subscriber);
            } else {
                _remoteEagleClientLogger.LogLine(
                        $"[REFRESH] No subscriber found for game {gameId}");
            }
        }

        public async Task Connect() {
            if (_disposed) {
                LogFlow("Connect() SKIPPED disposed=true");
                return;
            }

            // Prevent concurrent connection attempts
            if (_isConnecting) {
                LogFlow($"Connect() SKIPPED already_connecting=true");
                return;
            }

            // Check circuit breaker
            if (!_circuitBreaker.ShouldAttemptConnection()) {
                LogFlow($"Connect() BLOCKED circuit={_circuitBreaker.CurrentState}");
                LogConnectionEvent("connect_blocked", $"circuit={_circuitBreaker.CurrentState}");
                return;
            }

            _isConnecting = true;
            _awaitingCommandResponse = false;
            try {
                LogFlow($"Connect() START failures={_consecutiveFailures}");
                _lastConnectAttempt = DateTime.UtcNow;
                var expectedReconnect = _isExpectedReconnect;
                _isExpectedReconnect = false;

                // "Silent retry" = first retry after a fresh disconnect. We hide the
                // UX during this attempt so brief blips don't flash "Reconnecting...".
                bool silentRetry = _consecutiveFailures == 1 && !expectedReconnect;

                if (!expectedReconnect && !silentRetry) {
                    _currentState = _consecutiveFailures > 1 ? ConnectionState.Reconnecting
                                                             : ConnectionState.Connecting;
                    LogFlow($"State -> {_currentState}");
                }
                NextReconnectAttempt = null;
                LogConnectionEvent("connect_attempt");

                // Dispose existing streaming call before creating new one
                lock (this) {
                    _streamingCall?.Dispose();
                    _streamingCall = null;

                    // Cancel and dispose existing thread cancellation token
                    _threadCancellationTokenSource?.Cancel();
                    _threadCancellationTokenSource?.Dispose();
                    _threadCancellationTokenSource = new CancellationTokenSource();
                    _currentThreadToken = _threadCancellationTokenSource.Token;
                }

                var call = _grpcClient.StreamUpdates(
                        headers: _credentials,
                        deadline: GetDeadlineFromNow(),
                        cancellationToken: _cancellationToken);
                Queue<PostCommandRequest> pendingCommands = new Queue<PostCommandRequest>();
                try {
                    List<IClientConnectionSubscriber> subscribersToStream;
                    lock (this) {
                        _streamingCall = call;
                        _streamingCallThread = new Thread(HandleStreamingCall);
                        _streamingCallThread.Start();

                        // Collect subscribers under lock
                        subscribersToStream = _subscribers.Values.ToList();
                    }

                    // Stream subscriptions OUTSIDE lock (can await)
                    // Set state to SubscriptionPending while waiting for acks
                    LogFlow($"Streaming {subscribersToStream.Count} subscriptions");
                    if (subscribersToStream.Any() && !expectedReconnect && !silentRetry) {
                        _currentState = ConnectionState.SubscriptionPending;
                        LogFlow($"State -> {_currentState}");
                    }

                    bool allSucceeded = true;
                    foreach (var subscriber in subscribersToStream) {
                        if (!await StreamOneGameAsync(subscriber)) { allSucceeded = false; }
                    }

                    if (!allSucceeded) {
                        if (_authenticationRefreshInProgress || _connectAgainRequested ||
                            _currentState == ConnectionState.RefreshingAuthentication ||
                            _currentState == ConnectionState.AuthenticationRequired) {
                            LogFlow("SUBSCRIBE_FAILED suppressed during authentication recovery");
                            return;
                        }

                        // Subscription failed - this is critical because the game won't receive
                        // updates. Trigger reconnect rather than proceeding to Connected state.
                        LogFlow("SUBSCRIBE_FAILED triggering reconnect");
                        LogConnectionEvent(
                                "subscribe_failed",
                                "Subscription failed - triggering reconnect");

                        // Cleanup resources before reconnecting (consistent with other failure
                        // paths in HandleStreamingCall). This prevents the background thread from
                        // triggering its own reconnect when Connect() disposes the streaming call.
                        lock (this) {
                            _streamingCall?.Dispose();
                            _streamingCall = null;
                            _threadCancellationTokenSource?.Cancel();
                        }
                        MarkAllShardokGamesForResync();
                        CancelAllPendingSubscriptionAcks();

                        _circuitBreaker.RecordFailure();
                        ScheduleReconnect("SubscriptionFailed");
                        return;
                    }

                    lock (this) {
                        if (_disposed) return;

                        _lastSuccessfulConnect = DateTime.UtcNow;
                        _consecutiveFailures = 0;  // Reset backoff on successful connection
                        _currentState = ConnectionState.Connected;
                        LogFlow($"State -> {_currentState}");
                        _circuitBreaker.RecordSuccess();
                        LogConnectionEvent("connect_success");

                        // CRITICAL: Cancel any pending scheduled reconnect to prevent it from
                        // firing and killing this working connection
                        if (_retryTimer != null) {
                            _retryTimer.Enabled = false;
                            _retryTimer.Dispose();
                            _retryTimer = null;
                        }

                        // CRITICAL: Reset idle timer baseline before starting idle check
                        // Otherwise, on reconnect, _lastResponseReceived still has the old
                        // timestamp and CheckForIdleTimeout() will immediately kill this
                        // new connection thinking it's been idle for minutes
                        _lastResponseReceived = DateTime.UtcNow;
                        _lastIdleWarningLevel = 0;
                    }

                    // Start monitoring for idle timeout (relies on HTTP/2 keepalive)
                    StartIdleCheckTimer();

                    // Start sending heartbeats with sync status
                    StartHeartbeatTimer();

                    await TryPendingCommands();
                } catch (ObjectDisposedException e) {
                    // Channel is permanently disposed - notify caller to recreate connection
                    LogConnectionEvent("connect_failed", "ChannelDisposed");
                    _remoteEagleClientLogger.LogLine(
                            $"Channel disposed, triggering recreation: {e.Message}");
                    Action channelDeadHandler;
                    lock (this) {
                        if (_disposed) return;
                        _currentState = ConnectionState.Disconnected;
                        channelDeadHandler = OnChannelDead;
                    }
                    channelDeadHandler?.Invoke();
                } catch (Exception e) {
                    if (_disposed) {
                        LogFlow($"Connect failure ignored after disposal: {e.GetType().Name}");
                        return;
                    }

                    _circuitBreaker.RecordFailure();
                    LogConnectionEvent("connect_failed", e.GetType().Name);
                    _remoteEagleClientLogger.LogLine($"Got exception {e} in Connect()");
                    lock (this) {
                        while (pendingCommands.Any()) {
                            _pendingCommands.Add(pendingCommands.Dequeue());
                        }
                    }

                    // Clean up resources before reconnecting
                    lock (this) {
                        _streamingCall?.Dispose();
                        _streamingCall = null;
                        _threadCancellationTokenSource?.Cancel();
                    }
                    MarkAllShardokGamesForResync();
                    CancelAllPendingSubscriptionAcks();

                    // Schedule reconnect - without this, the connection dies silently
                    ScheduleReconnect($"ConnectFailed-{e.GetType().Name}");
                }
            } finally {
                _isConnecting = false;
                if (_connectAgainRequested && !_disposed) {
                    _connectAgainRequested = false;
                    RunConnectAsync();
                }
            }
        }

        private async Task<bool> TryPendingCommands() {
            if (_disposed) return true;

            // Try-acquire-or-skip: if another invocation is already draining the queue,
            // bail out immediately. The running invocation will process whatever is in
            // _pendingCommands at the moment it copied the list; anything that arrives
            // later (from a fresh PostRequest, or from a server response triggering
            // RemovePendingCommandsForGame + RefreshGameSubscription) will be picked up
            // by the next ActionResultResponse handler that calls TryPendingCommands.
            if (!await _tryPendingCommandsSemaphore.WaitAsync(0)) {
                _remoteEagleClientLogger.LogLine(
                        "TryPendingCommands skipped - another invocation is in progress");
                return true;
            }

            Queue<PostCommandRequest> pendingCommands = new();
            try {
                lock (this) {
                    foreach (var cmd in _pendingCommands) { pendingCommands.Enqueue(cmd); }

                    _pendingCommands = new();
                }
                if (pendingCommands.Any()) {
                    _remoteEagleClientLogger.LogLine($"preparing to post pending commands");

                    while (pendingCommands.Any()) {
                        if (_disposed) return true;

                        var nextCommand = pendingCommands.Dequeue();
                        if (_subscribers.TryGetValue(nextCommand.GameId, out var subscriber)) {
                            switch (nextCommand.Command.SealedValueCase) {
                                case UniversalCommand.SealedValueOneofCase.EagleCommand:
                                    Int64 providedToken =
                                            nextCommand.Command.EagleCommand.EagleToken;
                                    if (subscriber.CurrentEagleToken is Int64 eagleToken) {
                                        if (eagleToken == providedToken) {
                                            _remoteEagleClientLogger.LogLine(
                                                    "Found a matching pending command with token " +
                                                    eagleToken);
                                            await PostRequest(nextCommand);
                                        } else if (eagleToken > providedToken) {
                                            _remoteEagleClientLogger.LogLine(
                                                    $"{providedToken} seems to be stale (current token " +
                                                    $"{eagleToken}), dropping");
                                            // Server already processed this command and advanced
                                            // the token. Drop the stale command. The client
                                            // already has the latest state: we only reached this
                                            // branch because an ActionResultResponse delivered
                                            // eagleToken, which means the corresponding updates
                                            // have already been applied. Do NOT issue a mid-stream
                                            // SUBSCRIBE to "refresh" - that causes the server to
                                            // re-send results that are still in flight on this
                                            // same stream, leading to duplicate application of
                                            // ActionResultViews (e.g. Duplicate UH crashes).
                                        } else {
                                            _remoteEagleClientLogger.LogLine(
                                                    $"{providedToken} seems to be from the future, adding back to the queue");
                                            lock (this) { _pendingCommands.Add(nextCommand); }
                                        }
                                    } else {
                                        // CurrentEagleToken is null - we're in a battle or haven't
                                        // received commands yet. Keep the command pending without
                                        // retrying to avoid flooding the server with BAD_TOKEN
                                        // errors.
                                        _remoteEagleClientLogger.LogLine(
                                                $"No current token, keeping pending command with token {providedToken} for later");
                                        lock (this) { _pendingCommands.Add(nextCommand); }
                                    }

                                    break;
                                case UniversalCommand.SealedValueOneofCase.ShardokCommand: {
                                    Int64 providedShardokToken =
                                            nextCommand.Command.ShardokCommand.ShardokToken;
                                    var currentShardokToken = subscriber.CurrentShardokToken(
                                            nextCommand.Command.ShardokCommand.GameId);
                                    if (currentShardokToken is Int64 shardokToken &&
                                        shardokToken == providedShardokToken) {
                                        _remoteEagleClientLogger.LogLine(
                                                "Found a matching pending command with token " +
                                                shardokToken);
                                        await PostRequest(nextCommand);
                                    } else if (currentShardokToken == null) {
                                        // No current token - keep pending without retrying to avoid
                                        // flooding the server with BAD_TOKEN errors.
                                        _remoteEagleClientLogger.LogLine(
                                                $"No current Shardok token, keeping pending command with token {providedShardokToken} for later");
                                        lock (this) { _pendingCommands.Add(nextCommand); }
                                    } else {
                                        _remoteEagleClientLogger.LogLine(
                                                $"Shardok token mismatch: pending={providedShardokToken} " +
                                                $"current={currentShardokToken}, dropping");
                                        // Server already processed this command and advanced the
                                        // token. Drop the stale command. See the Eagle stale-token
                                        // branch above for why a mid-stream SUBSCRIBE to refresh
                                        // state is NOT safe here.
                                    }

                                    break;
                                }
                                case UniversalCommand.SealedValueOneofCase
                                        .ShardokPlacementCommands: {
                                    Int64 providedShardokToken =
                                            nextCommand.Command.ShardokPlacementCommands
                                                    .ShardokToken;
                                    var currentShardokToken = subscriber.CurrentShardokToken(
                                            nextCommand.Command.ShardokPlacementCommands.GameId);
                                    if (currentShardokToken is Int64 shardokToken &&
                                        shardokToken == providedShardokToken) {
                                        _remoteEagleClientLogger.LogLine(
                                                "Found a matching pending placement command with token " +
                                                shardokToken);
                                        await PostRequest(nextCommand);
                                    } else if (currentShardokToken == null) {
                                        // No current token - retry anyway
                                        _remoteEagleClientLogger.LogLine(
                                                $"No current Shardok token, retrying pending placement command with token {providedShardokToken}");
                                        await PostRequest(nextCommand);
                                    } else {
                                        _remoteEagleClientLogger.LogLine(
                                                $"Shardok placement token mismatch: pending={providedShardokToken} " +
                                                $"current={currentShardokToken}, dropping");
                                        // Server already processed this command and advanced the
                                        // token. Drop the stale command. See the Eagle stale-token
                                        // branch above for why a mid-stream SUBSCRIBE to refresh
                                        // state is NOT safe here.
                                    }

                                    break;
                                }
                            }
                        }
                    }

                    _remoteEagleClientLogger.LogLine($"pendingCommands completed");
                }

                return true;
            } catch (Exception e) {
                _remoteEagleClientLogger.LogLine($"Got exception {e} in TryPendingCommands()");
                if (!_disposed) {
                    lock (this) {
                        while (pendingCommands.Any()) {
                            _pendingCommands.Add(pendingCommands.Dequeue());
                        }
                    }
                }

                return false;
            } finally { _tryPendingCommandsSemaphore.Release(); }
        }

        public void Disconnect() {
            if (_disposed) return;

            _awaitingCommandResponse = false;
            lock (this) {
                _lastDisconnect = DateTime.UtcNow;
                LogConnectionEvent("disconnect_explicit");
                StopIdleCheckTimer();
                StopHeartbeatTimer();
                _streamingCall?.Dispose();
                _streamingCall = null;
            }
        }

        public void Dispose() {
            Thread threadToJoin = null;
            CancellationTokenSource tokenSourceToDispose = null;

            lock (this) {
                if (_disposed) return;
                _disposed = true;
                _awaitingCommandResponse = false;

                // Dispose timers first to stop any pending callbacks
                StopIdleCheckTimer();
                StopHeartbeatTimer();

                if (_retryTimer != null) {
                    _retryTimer.Enabled = false;
                    _retryTimer.Dispose();
                    _retryTimer = null;
                }

                // Dispose streaming call
                _streamingCall?.Dispose();
                _streamingCall = null;

                // Clear subscribers
                _subscribers.Clear();
                _lobbySubscriber = null;

                // Clear pending commands
                _pendingCommands.Clear();

                OnChannelDead = null;
                OnCommandError = null;

                // Cancel thread cancellation token (but don't dispose yet)
                _threadCancellationTokenSource?.Cancel();

                // Capture thread and token source references for cleanup outside lock
                threadToJoin = _streamingCallThread;
                _streamingCallThread = null;
                tokenSourceToDispose = _threadCancellationTokenSource;
                _threadCancellationTokenSource = null;
            }

            // Join thread OUTSIDE lock to avoid deadlock - the streaming thread
            // may be trying to acquire lock(this) when we're waiting for it
            if (threadToJoin != null) {
                if (!threadToJoin.Join(TimeSpan.FromSeconds(2))) {
                    Console.WriteLine("Warning: Streaming call thread did not exit gracefully");
                }
            }

            tokenSourceToDispose?.Dispose();
            // An async command drain can still be unwinding after disposal. SemaphoreSlim has no
            // resources to release unless its wait handle is requested, so leave it undisposed
            // rather than racing WaitAsync()/Release() on the retired connection.
        }

        public void SetLobbySubscriber(ILobbySubscriber subscriber) {
            _lobbySubscriber = subscriber;
        }

        /// <summary>
        /// Subscribe to a game and wait for server acknowledgment.
        /// Returns true if subscription was acknowledged, false if it failed or timed out.
        /// </summary>
        public async Task<bool> Subscribe(IClientConnectionSubscriber subscriber) {
            IClientConnectionSubscriber toStream;
            lock (this) {
                _subscribers[subscriber.GameId] = subscriber;
                toStream = subscriber;
                LogFlow($"SUBSCRIBER_ADD game={subscriber.GameId} " +
                        $"type={subscriber.GetType().Name} total={_subscribers.Count}");
            }

            // Set up callback so subscriber can check for pending commands.
            // This allows HandleAvailableCommands to skip loading stale commands
            // when a pending command with the same token is about to be posted.
            var gameId = subscriber.GameId;
            subscriber.HasPendingCommandWithToken = (token) =>
                    HasPendingEagleCommandWithToken(gameId, token);

            // Stream OUTSIDE lock (can await)
            return await StreamOneGameAsync(toStream);
        }

        public async Task PostError(EagleGameId gameId, string errorMessage, string stackTrace) {
            await DoWithStreamingCall(async (streamingCall) => {
                await streamingCall.RequestStream.WriteAsync(new UpdateStreamRequest {
                    ErrorRequest =
                            new ErrorRequest {
                                ErrorMessage = errorMessage,
                                GameId = gameId,
                                StackTrace = stackTrace
                            }
                });
                return true;
            });
        }

        public void Unsubscribe(IClientConnectionSubscriber subscriber) {
            lock (this) {
                if (_subscribers.TryGetValue(subscriber.GameId, out var currentSubscriber) &&
                    ReferenceEquals(currentSubscriber, subscriber)) {
                    _subscribers.Remove(subscriber.GameId);
                    LogFlow($"SUBSCRIBER_REMOVE game={subscriber.GameId} " +
                            $"type={subscriber.GetType().Name} total={_subscribers.Count}");
                } else if (currentSubscriber != null) {
                    LogFlow($"SUBSCRIBER_REMOVE_SKIPPED game={subscriber.GameId} " +
                            $"type={subscriber.GetType().Name} " +
                            $"currentType={currentSubscriber.GetType().Name} total={_subscribers.Count}");
                } else {
                    LogFlow($"SUBSCRIBER_REMOVE_MISSING game={subscriber.GameId} " +
                            $"type={subscriber.GetType().Name} total={_subscribers.Count}");
                }
            }
        }

        private async Task PostRequest(PostCommandRequest request) {
            try {
                lock (this) { _pendingCommands.Add(request); }

                var success = await DoWithStreamingCall(async (streamingCall) => {
                    await streamingCall.RequestStream.WriteAsync(
                            new UpdateStreamRequest { PostCommandRequest = request });
                    return true;
                });

                // IMPORTANT: Do NOT remove from pending here even if write succeeded.
                // WriteAsync completing only means data was written to local buffers,
                // not that the server received and processed it. The command stays in
                // _pendingCommands until we receive PostCommandResponse SUCCESS or
                // TryPendingCommands sees the token has advanced (command was processed).
                if (!success) {
                    _remoteEagleClientLogger.LogLine(
                            $"[POST] Command not sent (connection dead), keeping in pending queue for retry");
                } else {
                    _awaitingCommandResponse = true;
                    _lastCommandPostedAt = DateTime.UtcNow;
                }
            } catch (Exception e) {
                Console.WriteLine("Failed to post command: " + e);
                _remoteEagleClientLogger.LogLine($"[POST] Exception posting command: {e.Message}");
                // Leave in _pendingCommands for retry after reconnect
            }
        }

        private async Task
        PostUniversalCommand(EagleGameId gameId, UniversalCommand universalCommand) {
            var request = new PostCommandRequest { GameId = gameId, Command = universalCommand };

            await PostRequest(request);
        }

        public async Task PostEagleCommand(
                EagleGameId gameId,
                Int64 token,
                ProvinceId provinceId,
                SelectedCommand command) {
            var startTime = DateTime.UtcNow;

            await PostUniversalCommand(
                    gameId,
                    new UniversalCommand {
                        EagleCommand =
                                new EagleCommand {
                                    ProvinceId = provinceId,
                                    Command = command,
                                    EagleToken = token
                                }
                    });
            _timingsLogger.LogLine($"POST    {(DateTime.UtcNow - startTime).TotalMilliseconds} ms");
        }

        public async Task PostShardokCommand(
                EagleGameId eagleGameId,
                ShardokGameId shardokGameId,
                Int32 playerId,
                Int64 token,
                Int32 index,
                Int32? roll) {
            var startTime = DateTime.UtcNow;

            await PostUniversalCommand(
                    gameId: eagleGameId,
                    new UniversalCommand {
                        ShardokCommand =
                                new ShardokCommand {
                                    GameId = shardokGameId,
                                    PlayerId = playerId,
                                    ShardokToken = token,
                                    Index = index,
                                    Roll = roll
                                }
                    });

            _timingsLogger.LogLine(
                    $"PostShardokCommand took {(DateTime.UtcNow - startTime).TotalMilliseconds} ms");
        }

        public async Task PostShardokPlacementCommands(
                EagleGameId eagleGameId,
                ShardokGameId shardokGameId,
                Int32 playerId,
                List<ShardokPlacementCommands.Types.ShardokPlacementCommand> placementCommands,
                Int64 token) {
            var sc = new ShardokPlacementCommands {
                GameId = shardokGameId,
                PlayerId = playerId,
                ShardokToken = token
            };

            sc.PlacementCommands.AddRange(placementCommands);

            await PostUniversalCommand(
                    eagleGameId,
                    new UniversalCommand { ShardokPlacementCommands = sc });
        }

        private delegate Task<bool> WithStreamingCallDelegate(
                AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> call);

        /// <summary>
        /// Execute an action with the streaming call, with a timeout to prevent indefinite
        /// blocking. If the action takes longer than WriteAsyncTimeoutMs, we consider the
        /// connection dead and return false. This prevents thread pool exhaustion on dead
        /// connections.
        /// </summary>
        private async Task<bool> DoWithStreamingCall(WithStreamingCallDelegate action) {
            var sc = _streamingCall;
            if (sc == null) return false;

            try {
                using var timeoutCts = new CancellationTokenSource(WriteAsyncTimeoutMs);
                var actionTask = action(sc);
                var timeoutTask = Task.Delay(WriteAsyncTimeoutMs, timeoutCts.Token);

                var completedTask =
                        await Task.WhenAny(actionTask, timeoutTask).ConfigureAwait(false);

                if (completedTask == timeoutTask) {
                    // WriteAsync timed out - connection is dead
                    LogConnectionEvent(
                            "write_timeout",
                            $"WriteAsync timed out after {WriteAsyncTimeoutMs}ms");
                    _remoteEagleClientLogger.LogLine(
                            $"[WRITE_TIMEOUT] WriteAsync timed out after {WriteAsyncTimeoutMs}ms, triggering reconnect");

                    // Dispose the dead connection and schedule reconnect
                    lock (this) {
                        _streamingCall?.Dispose();
                        _streamingCall = null;
                    }
                    ScheduleReconnect("WriteAsyncTimeout");
                    return false;
                }

                // Cancel the timeout task since action completed
                timeoutCts.Cancel();
                return await actionTask.ConfigureAwait(false);
            } catch (RpcException e) {
                _remoteEagleClientLogger.LogLine(
                        $"[WRITE_ERROR] RpcException: {e.StatusCode} - {e.Message}");
                // Connection is broken - dispose and reconnect
                lock (this) {
                    _streamingCall?.Dispose();
                    _streamingCall = null;
                }
                if (e.StatusCode == StatusCode.Unauthenticated) {
                    await HandleUnauthenticatedAsync(e);
                } else if (e.StatusCode != StatusCode.Cancelled) {
                    // Cancelled is expected during normal shutdown, don't reconnect
                    ScheduleReconnect($"RpcException-{e.StatusCode}");
                }
                return false;
            } catch (OperationCanceledException) {
                // Timeout was triggered
                return false;
            } catch (Exception e) {
                _remoteEagleClientLogger.LogLine(
                        $"[WRITE_ERROR] Exception: {e.GetType().Name} - {e.Message}");
                // Unknown error - dispose and reconnect
                lock (this) {
                    _streamingCall?.Dispose();
                    _streamingCall = null;
                }
                ScheduleReconnect($"WriteException-{e.GetType().Name}");
                return false;
            }
        }

        /// <summary>
        /// Send a request on the streaming call with a timeout to prevent indefinite blocking.
        /// </summary>
        public async Task<bool> SendUpdateStreamRequestAsync(UpdateStreamRequest request) {
            AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc = null;
            lock (this) {
                sc = _streamingCall;
                if (sc == null) return false;
            }

            try {
                using var timeoutCts = new CancellationTokenSource(WriteAsyncTimeoutMs);
                using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
                        _cancellationToken,
                        timeoutCts.Token);

                var writeTask = sc.RequestStream.WriteAsync(request, linkedCts.Token);
                var timeoutTask = Task.Delay(WriteAsyncTimeoutMs, timeoutCts.Token);

                var completedTask =
                        await Task.WhenAny(writeTask, timeoutTask).ConfigureAwait(false);

                if (completedTask == timeoutTask) {
                    // WriteAsync timed out - connection is likely dead
                    LogConnectionEvent(
                            "write_timeout",
                            $"SendUpdateStreamRequestAsync timed out after {WriteAsyncTimeoutMs}ms");
                    _remoteEagleClientLogger.LogLine(
                            $"[WRITE_TIMEOUT] SendUpdateStreamRequestAsync timed out after {WriteAsyncTimeoutMs}ms");
                    return false;
                }

                // Cancel the timeout task since write completed
                timeoutCts.Cancel();
                await writeTask.ConfigureAwait(false);  // Observe any exception
                return true;
            } catch (RpcException e) {
                if (e.StatusCode == StatusCode.Cancelled) {
                    // This is expected when the connection is closed.
                    return false;
                } else {
                    throw;
                }
            } catch (OperationCanceledException) {
                // Timeout was triggered
                return false;
            }
        }

        public delegate Task<TResponseType> WithGrpcClientDelegateAsync<TResponseType>(
                Eagle.EagleClient client);

        private Timer _retryTimer = null;

        private void HandleGameUpdate(GameUpdate gameUpdate, DateTime receivedTime) {
            switch (gameUpdate.GameUpdateDetailsCase) {
                case GameUpdate.GameUpdateDetailsOneofCase.ErrorResponse:
                    LogFlow($"UPDATE ErrorResponse game={gameUpdate.GameId}");
                    _remoteEagleClientLogger.LogLine("Got an error response!");
                    MainQueue.Q.Enqueue(() => {
                        lock (this) { _subscribers.Remove(gameUpdate.GameId); }
                    });
                    break;

                case GameUpdate.GameUpdateDetailsOneofCase.StreamingTextResponse:
                    // Handle streaming text directly on gRPC thread - dictionary is thread-safe.
                    // Listeners are notified later via ProcessPendingUpdates() on main thread.
                    var str = gameUpdate.StreamingTextResponse;
                    if (str != null) {
                        var updatedText = ClientTextProvider.Provider.HandleNewStreamingText(
                                str.LlmIdentifier,
                                str.NewText,
                                str.StartingByteCount,
                                str.Completed,
                                str.StreamAttemptId);
                        if (str.Completed) {
                            ClientTextDiskCache.SaveCompletedText(
                                    gameUpdate.GameId,
                                    str.LlmIdentifier,
                                    updatedText);
                        }
                    }
                    // No MainQueue enqueue needed - ProcessPendingUpdates handles listener
                    // notification
                    break;

                case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
                    var arResp = gameUpdate.ActionResultResponse;
                    var arCount = arResp.ActionResultViews?.Count ?? 0;
                    var hasStartingState = gameUpdate.StartingState != null;
                    var hasCommands = arResp.AvailableCommands != null;
                    var cmdToken = hasCommands ? arResp.AvailableCommands.Token : -1;
                    var cmdCount = hasCommands
                                           ? arResp.AvailableCommands.CommandsByProvince?.Count ?? 0
                                           : 0;
                    var status = arResp.ServerGameStatus?.Status.ToString() ?? "null";
                    LogFlow($"UPDATE ActionResult game={gameUpdate.GameId} countAfter={arResp.UnfilteredResultCountAfter} " +
                            $"actionResults={arCount} startingState={hasStartingState} hasCommands={hasCommands} " +
                            $"token={cmdToken} cmdProvinces={cmdCount} status={status}");

                    // Update result counts IMMEDIATELY on the gRPC thread, before enqueueing.
                    lock (this) {
                        if (_subscribers.TryGetValue(gameUpdate.GameId, out var sub)) {
                            sub.UpdateResultCounts(gameUpdate);
                        }
                    }

                    MainQueue.Q.Enqueue(async () => {
                        IClientConnectionSubscriber subscriber;
                        lock (this) {
                            if (_subscribers.TryGetValue(gameUpdate.GameId, out subscriber)) {
                                try {
                                    subscriber.ReceiveGameUpdate(gameUpdate);
                                } catch (Exception e) {
                                    UnityEngine.Debug.LogException(e);
                                    _remoteEagleClientLogger.LogLine(
                                            $"Exception in ReceiveGameUpdate (ActionResult): {e}");
                                }
                            } else {
                                _subscribers.Remove(gameUpdate.GameId);
                            }
                        }

                        await TryPendingCommands();

                        var processTime = (DateTime.UtcNow - receivedTime).TotalMilliseconds;
                        if (processTime > 100.0) {
                            _timingsLogger.LogLine($"PROCESS {processTime} ms");
                        }
                    });
                    break;

                case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
                    var shardokResp = gameUpdate.ShardokActionResultResponse;
                    var shardokGameCount = shardokResp.ShardokGameResponses?.Count ?? 0;
                    // Log each Shardok game's sequence to detect missing results
                    foreach (var sgr in shardokResp.ShardokGameResponses) {
                        var resultCount = sgr.ActionResultViews?.Count ?? 0;
                        LogFlow($"UPDATE ShardokResult game={gameUpdate.GameId} " +
                                $"shardok={sgr.ShardokGameId} countAfter={sgr.NewResultViewCount} " +
                                $"results={resultCount}");
                    }

                    // Update result counts IMMEDIATELY on the gRPC thread, before enqueueing.
                    lock (this) {
                        if (_subscribers.TryGetValue(gameUpdate.GameId, out var sub2)) {
                            sub2.UpdateResultCounts(gameUpdate);
                        }
                    }

                    MainQueue.Q.Enqueue(async () => {
                        IClientConnectionSubscriber subscriber;
                        lock (this) {
                            if (_subscribers.TryGetValue(gameUpdate.GameId, out subscriber)) {
                                try {
                                    subscriber.ReceiveGameUpdate(gameUpdate);
                                } catch (Exception e) {
                                    UnityEngine.Debug.LogException(e);
                                    _remoteEagleClientLogger.LogLine(
                                            $"Exception in ReceiveGameUpdate (ShardokActionResult): {e}");
                                }
                            } else {
                                _subscribers.Remove(gameUpdate.GameId);
                            }
                        }

                        await TryPendingCommands();

                        var processTime = (DateTime.UtcNow - receivedTime).TotalMilliseconds;
                        if (processTime > 100.0) {
                            _timingsLogger.LogLine($"PROCESS {processTime} ms");
                        }
                    });
                    break;

                case GameUpdate.GameUpdateDetailsOneofCase.BattleProgressResponse:
                    LogFlow($"UPDATE BattleProgress game={gameUpdate.GameId} " +
                            $"battles={gameUpdate.BattleProgressResponse.UpdatedBattles.Count} " +
                            $"day={gameUpdate.BattleProgressResponse.CurrentDay}");
                    MainQueue.Q.Enqueue(() => {
                        IClientConnectionSubscriber subscriber;
                        lock (this) {
                            if (_subscribers.TryGetValue(gameUpdate.GameId, out subscriber)) {
                                try {
                                    subscriber.ReceiveGameUpdate(gameUpdate);
                                } catch (Exception e) {
                                    UnityEngine.Debug.LogException(e);
                                    _remoteEagleClientLogger.LogLine(
                                            $"Exception in ReceiveGameUpdate (BattleProgress): {e}");
                                }
                            }
                        }
                    });
                    break;

                case GameUpdate.GameUpdateDetailsOneofCase.ShardokBattleResetResponse:
                    LogFlow($"UPDATE BattleReset game={gameUpdate.GameId} " +
                            $"shardok={gameUpdate.ShardokBattleResetResponse.ShardokGameId}");
                    MainQueue.Q.Enqueue(() => {
                        IClientConnectionSubscriber subscriber;
                        lock (this) {
                            if (_subscribers.TryGetValue(gameUpdate.GameId, out subscriber)) {
                                try {
                                    subscriber.ReceiveGameUpdate(gameUpdate);
                                } catch (Exception e) {
                                    UnityEngine.Debug.LogException(e);
                                    _remoteEagleClientLogger.LogLine(
                                            $"Exception in ReceiveGameUpdate (BattleReset): {e}");
                                }
                            }
                        }
                    });
                    break;
            }
        }

        private async void HandleStreamingCall() {
            LogFlow("HandleStreamingCall STARTED");
            // Capture thread token outside try block so it's accessible in catch blocks
            CancellationToken threadToken;
            lock (this) { threadToken = _currentThreadToken; }
            try {
                AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc;
                lock (this) { sc = _streamingCall; }

                var waitStartTime = DateTime.UtcNow;
                while (sc != null && !_currentThreadToken.IsCancellationRequested &&
                       await sc.ResponseStream.MoveNext(_cancellationToken)) {
                    var receivedTime = DateTime.UtcNow;
                    _lastResponseReceived = receivedTime;
                    _lastIdleWarningLevel = 0;  // Reset idle warnings on data received
                    var waitTime = (receivedTime - waitStartTime).TotalMilliseconds;
                    if (waitTime > 100.0) { _timingsLogger.LogLine($"WAIT    {waitTime} ms"); }

                    lock (this) {
                        var current = sc.ResponseStream.Current;

                        switch (current.ResponseDetailsCase) {
                            case UpdateStreamResponse.ResponseDetailsOneofCase.HeartbeatResponse:
                                HandleHeartbeatResponse(current.HeartbeatResponse);
                                break;

                            case UpdateStreamResponse.ResponseDetailsOneofCase.GameUpdate:
                                HandleGameUpdate(current.GameUpdate, receivedTime);
                                break;

                            case UpdateStreamResponse.ResponseDetailsOneofCase.LobbyResponse: {
                                // Capture subscriber reference to avoid race condition - if
                                // subscriber changes or is cleared during dispose, we deliver to
                                // whoever was subscribed when the message arrived
                                var lobbySubscriber = _lobbySubscriber;
                                if (lobbySubscriber != null) {
                                    MainQueue.Q.Enqueue(() => {
                                        lobbySubscriber.ReceiveLobbyUpdate(current.LobbyResponse);
                                    });
                                }

                                break;
                            }

                            case UpdateStreamResponse.ResponseDetailsOneofCase.JoinGameResponse: {
                                var lobbySubscriber = _lobbySubscriber;
                                if (lobbySubscriber != null) {
                                    MainQueue.Q.Enqueue(() => {
                                        lobbySubscriber.HandleJoinGameResponse(
                                                current.JoinGameResponse);
                                    });
                                }

                                break;
                            }

                            case UpdateStreamResponse.ResponseDetailsOneofCase.CreateGameResponse: {
                                var lobbySubscriber = _lobbySubscriber;
                                if (lobbySubscriber != null) {
                                    MainQueue.Q.Enqueue(() => {
                                        lobbySubscriber.HandleCreateGameResponse(
                                                current.CreateGameResponse);
                                    });
                                }

                                break;
                            }

                            case UpdateStreamResponse.ResponseDetailsOneofCase
                                    .CreateGameAcceptedResponse: {
                                var lobbySubscriber = _lobbySubscriber;
                                if (lobbySubscriber != null) {
                                    MainQueue.Q.Enqueue(() => {
                                        lobbySubscriber.HandleCreateGameAcceptedResponse(
                                                current.CreateGameAcceptedResponse);
                                    });
                                }

                                break;
                            }

                            case UpdateStreamResponse.ResponseDetailsOneofCase
                                    .CustomBattleResponse: {
                                var lobbySubscriber = _lobbySubscriber;
                                if (lobbySubscriber != null) {
                                    MainQueue.Q.Enqueue(() => {
                                        lobbySubscriber.HandleCustomBattleResponse(
                                                current.CustomBattleResponse);
                                    });
                                }

                                break;
                            }

                            case UpdateStreamResponse.ResponseDetailsOneofCase.HexMapResponse:
                                var hexMapResponse = current.HexMapResponse;
                                foreach (var mapInfo in hexMapResponse.MapInfos) {
                                    HexMapStorage.Instance.Save(
                                            mapInfo.MapName,
                                            mapInfo.MapHash,
                                            mapInfo.Map);
                                }

                                break;

                            case UpdateStreamResponse.ResponseDetailsOneofCase.SubscriptionAck:
                                var ack = current.SubscriptionAck;
                                _remoteEagleClientLogger.LogLine(
                                        $"[SUBSCRIBE] Received SubscriptionAck for game {ack.GameId}, " +
                                        $"success={ack.Success}");
                                // Get TCS under lock, but call TrySetResult outside to avoid
                                // potential deadlock from synchronous continuations
                                TaskCompletionSource<SubscriptionAck> ackTcs = null;
                                lock (_pendingSubscriptionAcks) {
                                    if (_pendingSubscriptionAcks.TryGetValue(
                                                ack.GameId,
                                                out ackTcs)) {
                                        _pendingSubscriptionAcks.Remove(ack.GameId);
                                    }
                                }
                                ackTcs?.TrySetResult(ack);

                                break;

                            case UpdateStreamResponse.ResponseDetailsOneofCase.PostCommandResponse:
                                var postResponse = current.PostCommandResponse;
                                if (postResponse.Status == PostCommandResponse.Types.Status.Error) {
                                    _remoteEagleClientLogger.LogLine(
                                            $"[POST] Server returned ERROR for game {postResponse.GameId}: " +
                                            $"{postResponse.ErrorMessage}");
                                    LogConnectionEvent(
                                            "post_command_error",
                                            postResponse.ErrorMessage);

                                    // Command was rejected by server. Remove from pending to avoid
                                    // infinite retry loop, and refresh state to get valid commands.
                                    RemovePendingCommandsForGame(postResponse.GameId);
                                    RefreshGameSubscription(postResponse.GameId);

                                    // Notify UI to display the error to the user
                                    OnCommandError?.Invoke(postResponse.ErrorMessage);
                                } else if (
                                        postResponse.Status ==
                                        PostCommandResponse.Types.Status.BadToken) {
                                    _remoteEagleClientLogger.LogLine(
                                            $"[POST] Server returned BAD_TOKEN for game {postResponse.GameId} " +
                                            "- command rejected, refreshing state");
                                    // Server rejected command due to stale token. Remove from
                                    // pending (already processed) and re-subscribe to ensure we
                                    // have current state.
                                    RemovePendingCommandsForGame(postResponse.GameId);
                                    RefreshGameSubscription(postResponse.GameId);
                                } else if (
                                        postResponse.Status ==
                                        PostCommandResponse.Types.Status.Success) {
                                    _remoteEagleClientLogger.LogLine(
                                            $"[POST] Server confirmed command for game {postResponse.GameId}");
                                    // Command was successfully processed. Remove from pending.
                                    RemovePendingCommandsForGame(postResponse.GameId);
                                }
                                // UNKNOWN is benign (old servers that don't set status)

                                break;

                            case UpdateStreamResponse.ResponseDetailsOneofCase
                                    .ClientUpdateAvailable:
                                var updateNotification = current.ClientUpdateAvailable;
                                _remoteEagleClientLogger.LogLine(
                                        $"[UPDATE] Client update available: platform={updateNotification.Platform} " +
                                        $"version={updateNotification.Version} required={updateNotification.Required}");
                                // Dispatch to main thread for UI handling
                                MainQueue.Q.Enqueue(() => {
                                    UpdateNotificationManager.ReceiveClientUpdateAvailable(
                                            updateNotification);
                                });
                                break;
                        }

                        sc = _streamingCall;
                    }

                    waitStartTime = DateTime.UtcNow;
                }

                // While loop exited normally (not via exception)
                var scNull = sc == null;
                var tokenCancelled = _currentThreadToken.IsCancellationRequested;
                LogFlow($"HandleStreamingCall ENDED normally: sc_null={scNull} token_cancelled={tokenCancelled}");
                _remoteEagleClientLogger.LogLine(
                        $"Stream ended normally: sc_null={scNull} token_cancelled={tokenCancelled}");

                // Only schedule reconnect if:
                // 1. App is not shutting down (main token not cancelled)
                // 2. Thread token was NOT cancelled (if it was, something else like
                //    SyncMismatch already disposed the call and scheduled reconnect)
                if (!_disposed && !_cancellationToken.IsCancellationRequested && !tokenCancelled) {
                    lock (this) {
                        _lastDisconnect = DateTime.UtcNow;
                        MarkAllShardokGamesForResync();
                        CancelAllPendingSubscriptionAcks();
                    }
                    LogConnectionEvent("disconnect", "StreamEndedNormally");
                    // Stream ended normally - reconnect immediately without backoff
                    LogFlow("RECONNECT_IMMEDIATE reason=StreamEndedNormally");
                    _isExpectedReconnect = true;
                    RunConnectAsync();
                }
            } catch (RpcException e) {
                if (e.StatusCode == StatusCode.Unauthenticated) {
                    await HandleUnauthenticatedAsync(e);
                    return;
                }

                lock (this) {
                    if (_disposed) {
                        LogFlow($"RpcException: skipping reconnect (disposed, status={e.StatusCode})");
                        return;
                    }

                    _lastDisconnect = DateTime.UtcNow;
                    _lastDisconnectReason = e.StatusCode;

                    // Mark all active Shardok games for full state resync
                    MarkAllShardokGamesForResync();
                    CancelAllPendingSubscriptionAcks();

                    switch (e.StatusCode) {
                        case StatusCode.Cancelled:
                            LogConnectionEvent("disconnect", "Cancelled", StatusCode.Cancelled);
                            _remoteEagleClientLogger.LogLine($"Got exception {e}");
                            // If main app is shutting down, don't reconnect
                            if (_cancellationToken.IsCancellationRequested) { return; }
                            // If thread token was cancelled, something else (Connect() or
                            // CheckForIdleTimeout) already initiated reconnect
                            if (threadToken.IsCancellationRequested) {
                                LogFlow("Cancelled: skipping reconnect (thread token cancelled)");
                                return;
                            }

                            ScheduleReconnect("Cancelled");

                            break;

                        case StatusCode.Internal:
                            LogConnectionEvent("disconnect", "Internal error", StatusCode.Internal);
                            _remoteEagleClientLogger.LogLine($"Got exception {e}");
                            ScheduleReconnect("Internal");
                            break;

                        case StatusCode.DeadlineExceeded:
                            LogConnectionEvent(
                                    "disconnect",
                                    "Deadline exceeded",
                                    StatusCode.DeadlineExceeded);
                            _remoteEagleClientLogger.LogLine("Got DeadlineExceeded");
                            // Deadline exceeded is expected (5 min timeout) - reconnect immediately
                            LogFlow("RECONNECT_IMMEDIATE reason=DeadlineExceeded");
                            _isExpectedReconnect = true;
                            RunConnectAsync();
                            break;

                        case StatusCode.Unavailable:
                            LogConnectionEvent("disconnect", "Unavailable", StatusCode.Unavailable);
                            _remoteEagleClientLogger.LogLine("Got Unavailable");
                            ScheduleReconnect("Unavailable");
                            break;

                        default:
                            LogConnectionEvent(
                                    "disconnect",
                                    $"Unknown: {e.StatusCode}",
                                    e.StatusCode);
                            _remoteEagleClientLogger.LogLine(
                                    $"Something went very wrong, caught exception {e}");
                            ScheduleReconnect($"Unknown-{e.StatusCode}");
                            break;
                    }
                }
            } catch (ObjectDisposedException e) {
                if (_disposed) {
                    LogFlow("ObjectDisposed: skipping reconnect (connection disposed)");
                    return;
                }

                _lastDisconnect = DateTime.UtcNow;
                LogConnectionEvent("disconnect", "ObjectDisposed");
                _remoteEagleClientLogger.LogLine($"Got ObjectDisposedException {e}");

                // Mark all active Shardok games for full state resync
                MarkAllShardokGamesForResync();
                CancelAllPendingSubscriptionAcks();

                // If thread token was cancelled, something else (like CheckForIdleTimeout)
                // already disposed the call and initiated reconnect. Don't schedule another.
                if (threadToken.IsCancellationRequested) {
                    LogFlow("ObjectDisposed: skipping reconnect (thread token cancelled)");
                    return;
                }

                ScheduleReconnect("ObjectDisposed");
            } catch (Exception e) {
                if (_disposed) {
                    LogFlow($"UnexpectedException: skipping reconnect (connection disposed, type={e.GetType().Name})");
                    return;
                }

                // Catch-all for any unexpected exceptions. Without this, exceptions in an
                // async void method can crash the process or be silently lost, leaving the
                // connection dead with no recovery.
                _lastDisconnect = DateTime.UtcNow;
                LogConnectionEvent("disconnect", $"UnexpectedException: {e.GetType().Name}");
                _remoteEagleClientLogger.LogLine(
                        $"UNEXPECTED exception in HandleStreamingCall: {e}");

                MarkAllShardokGamesForResync();
                CancelAllPendingSubscriptionAcks();

                // If thread token was cancelled, something else already initiated reconnect
                if (threadToken.IsCancellationRequested) {
                    LogFlow($"UnexpectedException: skipping reconnect (thread token cancelled)");
                    return;
                }

                ScheduleReconnect($"UnexpectedException-{e.GetType().Name}");
            }

            return;
        }

        private async Task HandleUnauthenticatedAsync(RpcException exception) {
            lock (this) {
                if (_disposed) return;
                if (_authenticationRefreshInProgress ||
                    _currentState == ConnectionState.AuthenticationRequired) {
                    return;
                }

                _authenticationRefreshInProgress = true;
                _lastDisconnect = DateTime.UtcNow;
                _lastDisconnectReason = StatusCode.Unauthenticated;
                _currentState = ConnectionState.RefreshingAuthentication;
                NextReconnectAttempt = null;
                MarkAllShardokGamesForResync();
                CancelAllPendingSubscriptionAcks();
            }

            LogFlow("State -> RefreshingAuthentication");
            LogConnectionEvent("disconnect", exception.Status.Detail, StatusCode.Unauthenticated);

            bool refreshed = false;
            if (_tryRefreshAuthenticationAsync != null) {
                try {
                    refreshed = await _tryRefreshAuthenticationAsync();
                } catch (Exception refreshException) {
                    _remoteEagleClientLogger.LogLine(
                            $"Authentication refresh failed: {refreshException}");
                }
            }

            bool connectNow;
            lock (this) {
                if (_disposed) return;

                if (!refreshed) {
                    _authenticationRefreshInProgress = false;
                    _currentState = ConnectionState.AuthenticationRequired;
                    LogFlow("State -> AuthenticationRequired");
                    LogConnectionEvent(
                            "authentication_required",
                            "Token refresh failed; user must sign in again",
                            StatusCode.Unauthenticated);
                    return;
                }

                _authenticationRefreshInProgress = false;
                _isExpectedReconnect = true;
                _consecutiveFailures = 0;
                _currentState = ConnectionState.Connecting;
                connectNow = !_isConnecting;
                _connectAgainRequested = !connectNow;
            }

            LogFlow("Authentication refreshed; reconnecting with new access token");
            if (connectNow) { RunConnectAsync(); }
        }

        /// <summary>
        /// Mark all active Shardok games for resync across all subscribers.
        /// Called when connection drops to ensure state consistency on reconnect.
        /// </summary>
        private void MarkAllShardokGamesForResync() {
            foreach (var updater in _subscribers.Values.OfType<GameModelUpdater>()) {
                updater.MarkAllShardokForResync();
            }
        }

        /// <summary>
        /// Cancel all pending subscription acknowledgments.
        /// Called when connection drops to unblock any waiting subscription tasks.
        /// </summary>
        private void CancelAllPendingSubscriptionAcks() {
            List<TaskCompletionSource<SubscriptionAck>> tcsList;
            lock (_pendingSubscriptionAcks) {
                // Snapshot values and clear dictionary BEFORE cancelling to avoid
                // "Collection was modified" exception. TrySetCanceled() can trigger
                // synchronous continuations that re-enter this lock (reentrant) and
                // call Remove() while we're still iterating.
                tcsList = _pendingSubscriptionAcks.Values.ToList();
                _pendingSubscriptionAcks.Clear();
            }

            // Cancel outside lock to avoid reentrancy issues
            foreach (var tcs in tcsList) { tcs.TrySetCanceled(); }
        }

        private void StartIdleCheckTimer() {
            StopIdleCheckTimer();
            var timer = new Timer {
                AutoReset = true,
                Interval = 5000  // Check every 5 seconds
            };
            timer.Elapsed += (sender, args) => {
                if (_disposed) return;

                try {
                    // Log BEFORE callback to confirm timer is firing
                    Console.WriteLine($"[IDLE_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
                    CheckForIdleTimeout();
                } catch (Exception e) {
                    // Timer callbacks must not throw - it can stop the timer from firing again
                    Console.WriteLine($"[IDLE_TIMER] Exception in callback: {e}");
                    _remoteEagleClientLogger.LogLine($"Exception in idle check timer: {e}");
                }
            };

            lock (this) {
                if (_disposed) {
                    timer.Dispose();
                    return;
                }
                _idleCheckTimer = timer;
                _idleCheckTimer.Enabled = true;
            }
        }

        private void StopIdleCheckTimer() {
            if (_idleCheckTimer != null) {
                _idleCheckTimer.Enabled = false;
                _idleCheckTimer.Dispose();
                _idleCheckTimer = null;
            }
        }

        private void CheckForIdleTimeout() {
            if (_disposed) return;

            var idleTime = (DateTime.UtcNow - _lastResponseReceived).TotalSeconds;

            // Log every check when idle > 5s to diagnose timer issues
            if (idleTime > 5.0) {
                _remoteEagleClientLogger.LogLine(
                        $"[IDLE_CHECK] idle_time={idleTime:F1}s, cancelled={_cancellationToken.IsCancellationRequested}");
            }

            if (_cancellationToken.IsCancellationRequested) { return; }

            // Check for command response timeout - if we posted a command and haven't
            // heard back, reconnect proactively rather than waiting for heartbeat sync
            if (_awaitingCommandResponse) {
                var commandWaitTime = (DateTime.UtcNow - _lastCommandPostedAt).TotalSeconds;
                if (commandWaitTime > CommandResponseTimeoutSeconds) {
                    _remoteEagleClientLogger.LogLine(
                            $"[COMMAND_TIMEOUT] No response in {commandWaitTime:F1}s, forcing reconnect");
                    LogConnectionEvent(
                            "command_response_timeout",
                            $"wait_time={commandWaitTime:F1}s");
                    _awaitingCommandResponse = false;

                    lock (this) { _threadCancellationTokenSource?.Cancel(); }

                    var toCancel = _streamingCall;
                    if (toCancel != null) {
                        toCancel.Dispose();
                        lock (this) { _streamingCall = null; }
                        _isExpectedReconnect = true;
                        RunConnectAsync();
                    }

                    return;
                }
            }

            // Early warnings at 10s and 20s to help diagnose slow connections
            if (idleTime > 20.0 && _lastIdleWarningLevel < 2) {
                _lastIdleWarningLevel = 2;
                _remoteEagleClientLogger.LogLine(
                        $"[IDLE_WARNING] No messages received in {idleTime:F1}s (timeout at {MaxIdleSeconds}s)");
                LogConnectionEvent("idle_warning_20s", $"idle_time={idleTime:F1}s");
            } else if (idleTime > 10.0 && _lastIdleWarningLevel < 1) {
                _lastIdleWarningLevel = 1;
                _remoteEagleClientLogger.LogLine(
                        $"[IDLE_WARNING] No messages received in {idleTime:F1}s (timeout at {MaxIdleSeconds}s)");
                LogConnectionEvent("idle_warning_10s", $"idle_time={idleTime:F1}s");
            }

            if (idleTime > MaxIdleSeconds) {
                _remoteEagleClientLogger.LogLine(
                        $"[IDLE_TIMEOUT] No messages received in {idleTime:F1}s (max={MaxIdleSeconds}s), forcing reconnect");
                LogConnectionEvent("idle_timeout", $"idle_time={idleTime:F1}s");

                // Cancel thread token BEFORE disposing the call. This prevents
                // HandleStreamingCall from scheduling its own reconnect when it
                // catches the ObjectDisposedException from the disposed call.
                // Without this, we get a race: both CheckForIdleTimeout and
                // HandleStreamingCall try to reconnect, leading to state confusion.
                lock (this) { _threadCancellationTokenSource?.Cancel(); }

                var toCancel = _streamingCall;
                if (toCancel != null) {
                    toCancel.Dispose();
                    lock (this) { _streamingCall = null; }
                    _isExpectedReconnect = true;
                    RunConnectAsync();
                }
            }
        }

        private void StartHeartbeatTimer() {
            StopHeartbeatTimer();
            var timer = new Timer { AutoReset = true, Interval = HeartbeatIntervalSeconds * 1000 };
            timer.Elapsed += (sender, args) => {
                if (_disposed) return;

                try {
                    // Log BEFORE Task.Run to confirm timer is firing
                    Console.WriteLine(
                            $"[HEARTBEAT_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
                    _ = Task.Run(async () => {
                        try {
                            if (_disposed) return;
                            await SendHeartbeat();
                        } catch (Exception e) {
                            // Log but don't rethrow - exceptions in Task.Run are silently lost
                            Console.WriteLine($"[HEARTBEAT] Exception in SendHeartbeat: {e}");
                            _remoteEagleClientLogger.LogLine($"Exception in SendHeartbeat: {e}");
                        }
                    });
                } catch (Exception e) {
                    // Timer callbacks must not throw
                    Console.WriteLine($"[HEARTBEAT_TIMER] Exception in callback: {e}");
                    _remoteEagleClientLogger.LogLine($"Exception in heartbeat timer: {e}");
                }
            };

            lock (this) {
                if (_disposed) {
                    timer.Dispose();
                    return;
                }
                _heartbeatTimer = timer;
                _heartbeatTimer.Enabled = true;
            }
        }

        private void StopHeartbeatTimer() {
            if (_heartbeatTimer != null) {
                _heartbeatTimer.Enabled = false;
                _heartbeatTimer.Dispose();
                _heartbeatTimer = null;
            }
        }

        private async Task SendHeartbeat() {
            if (_disposed) return;

            LogFlow($"HEARTBEAT_START state={_currentState}");

            if (_cancellationToken.IsCancellationRequested) {
                LogFlow("HEARTBEAT_SKIP reason=cancellation_requested");
                return;
            }
            if (_currentState != ConnectionState.Connected) {
                LogFlow($"HEARTBEAT_SKIP reason=not_connected state={_currentState}");
                return;
            }

            // Build sync status for all subscribed games
            var heartbeatRequest = new HeartbeatRequest {
                ClientTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
            };

            List<IClientConnectionSubscriber> subscribers;
            lock (this) { subscribers = _subscribers.Values.ToList(); }

            foreach (var subscriber in subscribers) {
                var gameSyncStatus = new GameSyncStatus {
                    GameId = subscriber.GameId,
                    UnfilteredResultCount = subscriber.LastUnfilteredResultCount
                };

                foreach (var shardokStatus in subscriber.ShardokViewStatuses) {
                    gameSyncStatus.ShardokSyncStatuses.Add(new ShardokSyncStatus {
                        ShardokGameId = shardokStatus.shardokGameId,
                        FilteredResultCount = shardokStatus.filteredResultCount
                    });
                }

                heartbeatRequest.GameSyncStatuses.Add(gameSyncStatus);
            }

            var request = new UpdateStreamRequest { HeartbeatRequest = heartbeatRequest };

            var sent = await SendUpdateStreamRequestAsync(request);
            if (sent) {
                var gameInfo = string.Join(
                        ", ",
                        heartbeatRequest.GameSyncStatuses.Select(
                                g => $"{g.GameId}:{g.UnfilteredResultCount}"));
                LogFlow($"HEARTBEAT_SENT games={heartbeatRequest.GameSyncStatuses.Count} ({gameInfo})");
            } else {
                LogFlow("HEARTBEAT_FAILED stream_unavailable");
            }
        }

        // Grace period after connect before sync mismatch triggers reconnect.
        // Allows time to receive and process historical results after fresh subscribe.
        private const double SyncMismatchGracePeriodSeconds = 5.0;

        private void HandleHeartbeatResponse(HeartbeatResponse response) {
            LogFlow($"HEARTBEAT_RESPONSE server_ts={response.ServerTimestamp}");
            _remoteEagleClientLogger.LogLine(
                    $"[HEARTBEAT] Got response, server_timestamp={response.ServerTimestamp}");

            // Check for sync mismatches reported by server
            bool hasMismatch = false;
            foreach (var syncResult in response.GameSyncResults) {
                if (!syncResult.EagleInSync) {
                    LogFlow($"SYNC_MISMATCH game={syncResult.GameId} server_count={syncResult.ServerUnfilteredResultCount}");
                    _remoteEagleClientLogger.LogLine(
                            $"[SYNC_MISMATCH] Game {syncResult.GameId}: Eagle out of sync, " +
                            $"server has {syncResult.ServerUnfilteredResultCount} results");
                    LogConnectionEvent(
                            "sync_mismatch_eagle",
                            $"game={syncResult.GameId}, server_count={syncResult.ServerUnfilteredResultCount}");
                    hasMismatch = true;
                }

                foreach (var shardokResult in syncResult.ShardokSyncResults) {
                    if (!shardokResult.InSync) {
                        _remoteEagleClientLogger.LogLine(
                                $"[SYNC_MISMATCH] Game {syncResult.GameId}, Shardok {shardokResult.ShardokGameId}: " +
                                $"out of sync, server has {shardokResult.ServerFilteredResultCount} results");
                        LogConnectionEvent(
                                "sync_mismatch_shardok",
                                $"game={syncResult.GameId}, shardok={shardokResult.ShardokGameId}, " +
                                        $"server_count={shardokResult.ServerFilteredResultCount}");
                        hasMismatch = true;
                    }
                }
            }

            if (!hasMismatch) return;

            // Grace period after connect - allow time to receive historical results
            if (_lastSuccessfulConnect.HasValue) {
                var timeSinceConnect =
                        (DateTime.UtcNow - _lastSuccessfulConnect.Value).TotalSeconds;
                if (timeSinceConnect < SyncMismatchGracePeriodSeconds) {
                    _remoteEagleClientLogger.LogLine(
                            $"[SYNC_MISMATCH] Ignoring mismatch during grace period ({timeSinceConnect:F1}s < {SyncMismatchGracePeriodSeconds}s)");
                    return;
                }
            }

            // Trigger resync by reconnecting - this will re-subscribe with current counts
            // and the server will send missing updates
            _remoteEagleClientLogger.LogLine(
                    "[SYNC_MISMATCH] Detected sync mismatch, triggering reconnect to resync");
            LogConnectionEvent("sync_mismatch_reconnect", "Triggering reconnect to resync");

            // Schedule reconnect to resync
            // Stop timers FIRST to prevent them firing during reconnection
            StopHeartbeatTimer();
            StopIdleCheckTimer();
            lock (this) {
                _streamingCall?.Dispose();
                _streamingCall = null;
                _threadCancellationTokenSource?.Cancel();
            }
            MarkAllShardokGamesForResync();
            CancelAllPendingSubscriptionAcks();
            ScheduleReconnect("SyncMismatch");
        }
    }
}
