using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using common;
using common.GUIUtils;
using eagle.Notifications;
using Eagle0.Tutorial;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
using Net.Eagle0.Shardok.Common;
using Google.Protobuf.Collections;
using TMPro;
using UnityEngine;
using Logger = common.Logger;
using Notification = eagle.Notifications.Notification;

namespace eagle {
    using UnitId = Int32;
    using FactionId = Int32;
    using PlayerId = Int32;
    using BattalionId = Int32;
    using HeroId = Int32;
    using ProvinceId = Int32;
    using ShardokGameId = String;
    using GameId = Int64;
    using TokenId = Int64;

    public interface IGameModel {
        FactionId? PlayerId { get; }
        Date CurrentDate { get; }
        IDictionary<HeroId, HeroView> Heroes { get; }
        IDictionary<ProvinceId, ProvinceView> Provinces { get; }
        IDictionary<FactionId, FactionView> ActiveFactions { get; }
        IDictionary<BattalionTypeId, BattalionType> BattalionTypes { get; }

        Dictionary<ProvinceId, OneProvinceAvailableCommands> AvailableCommandsByProvince { get; }
        List<AvailableCommand> AvailableCommandsForProvince(ProvinceId pid);
        ProvinceId SuggestedProvinceId { get; }

        TokenId? CommandToken { get; }
        TokenId LastPostedToken { get; }

        IList<ShardokBattleView> ShardokBattles { get; }
        int BattleDay { get; }

        ConcurrentDictionary<String, ShardokGameModel> ShardokGameModels { get; }

        private bool ShardokGameModelIsRunning(ShardokGameModel sgm) =>
                sgm.GameStatus != null &&
                (sgm.GameStatus.State == GameStatus.Types.State.GameRunning ||
                 sgm.GameStatus.State == GameStatus.Types.State.SetUp);
        Dictionary<String, ShardokGameModel> RunningShardokGameModels =>
                ShardokGameModels.Where(kv => ShardokGameModelIsRunning(kv.Value))
                        .ToDictionary(kv => kv.Key, kv => kv.Value);

        FactionView MaybeDestroyedFaction(FactionId factionId);
        string FactionName(FactionId factionId);

        public List<ProvinceId> ProvincesForFaction(FactionId factionId);

        public List<ChronicleEntry> ChronicleEntries { get; }

        GameType GameType { get; }

        EagleMapInfo EagleMap { get; }
    }

    public class GameModelUpdater : IClientConnectionSubscriber, IGameStateProvider {
        private FactionId? PlayerId => _currentModel.PlayerId;
        public Int64? CurrentEagleToken => _currentModel.CommandToken;
        public Int64? CurrentShardokToken(string shardokGameId) {
            if (_currentModel.ShardokGameModels.TryGetValue(shardokGameId, out var sgm)) {
                return sgm.History.Count;
            }
            return null;
        }

        public delegate void ReceiveNotes(
                string title,
                string text,
                string llmId,
                List<ProvinceId> pids,
                List<HeroView> displayedHeroes);

        public Action<IGameModel> UpdateAction { get; set; }
        public Action<ActionResultView> ActionResultAction { get; set; }
        public ReceiveNotes NoteRecipient { get; set; }

        /// <summary>
        /// Callback to check if there's a pending command with the specified token.
        /// Set by PersistentClientConnection. When this returns true, HandleAvailableCommands
        /// will set the token (for TryPendingCommands matching) but not load the commands,
        /// since the pending command will be posted and we'll get fresh commands after.
        /// </summary>
        public Func<long, bool> HasPendingCommandWithToken { get; set; }

        private void Notify(Notification notification) {
            NoteRecipient(
                    notification.Title,
                    notification.Text,
                    notification.LlmId,
                    notification.ProvinceIds,
                    notification.DisplayedHeroes);
        }

        private readonly ResourceFetcher resourceFetcher;

        public long GameId { get; }

        public int LastUnfilteredResultCount {
            get {
                lock (_resultCountLock) { return _lastUnfilteredResultCount; }
            }
        }

        public List<IClientConnectionSubscriber.ShardokViewStatus> ShardokViewStatuses =>
                _currentModel.ShardokGameModels
                        .Select(sgm => {
                            var needsResync = _shardokNeedsResync.GetValueOrDefault(sgm.Key, false);
                            // Use thread-safe count from gRPC thread updates. Fall back to 0 if
                            // not yet tracked (avoids accessing non-thread-safe History.Count).
                            var count = _shardokResultCounts.GetValueOrDefault(sgm.Key, 0);
                            return new IClientConnectionSubscriber.ShardokViewStatus {
                                shardokGameId = sgm.Key,
                                filteredResultCount = needsResync ? 0 : count,
                                requestFullResync = needsResync
                            };
                        })
                        .ToList();

        public List<IClientConnectionSubscriber.StreamingTextStatus> StreamingTextStatuses =>
                ClientTextProvider.Provider.All()
                        .Select(kv => new IClientConnectionSubscriber.StreamingTextStatus {
                            llmIdentifier = kv.Key,
                            knownByteCount = Encoding.UTF8.GetBytes(kv.Value.Text).Length,
                            knownComplete = kv.Value.Completed
                        })
                        .ToList();

        private PersistentClientConnection PersistentConnection { get; }

        public ErrorHandler ErrorHandler;

        // Thread-safe: updated from gRPC thread via UpdateResultCounts, read from main thread
        private int _lastUnfilteredResultCount = 0;
        private readonly object _resultCountLock = new();

        private readonly Logger _connectionLogger = Logger.GetLogger("ConnectionLogger");

        // Track when a command was submitted for "Processing..." display
        // Only show "Processing..." if command has been pending for > 500ms
        private DateTime? _commandSubmittedTime;
        private const double ProcessingDisplayDelayMs = 500.0;

        // Turn mismatch detection: server says YourTurn but client has no commands
        // Track when mismatch was first detected to avoid false positives during normal updates
        private DateTime? _turnMismatchDetectedTime;
        private const double TurnMismatchGracePeriodSeconds = 3.0;

        // Track which Shardok games need full state resync after connection drop
        // Thread-safe: accessed from both connection thread and Unity main thread
        private readonly ConcurrentDictionary<string, bool> _shardokNeedsResync =
                new ConcurrentDictionary<string, bool>();

        // Thread-safe Shardok result counts: updated from gRPC thread via UpdateResultCounts
        // Used by ShardokViewStatuses to report accurate counts even when MainQueue is blocked
        private readonly ConcurrentDictionary<string, int> _shardokResultCounts =
                new ConcurrentDictionary<string, int>();

        // State synced with server
        private struct ConcreteGameModel : IGameModel {
            public PlayerId? PlayerId { get; set; }

            public GameStateView GsView { get; set; }

            public int HistoryCount { get; set; }
            public Date CurrentDate => GsView.CurrentDate;
            public IDictionary<HeroId, HeroView> Heroes => GsView.Heroes;
            public IDictionary<ProvinceId, ProvinceView> Provinces => GsView.Provinces;
            public IDictionary<FactionId, FactionView> ActiveFactions => GsView.Factions;
            public IDictionary<FactionId, FactionView> DestroyedFactions =>
                    GsView.DestroyedFactions;
            public IList<ShardokBattleView> ShardokBattles => GsView.OutstandingBattles;
            public int BattleDay { get; set; }
            public IDictionary<BattalionTypeId, BattalionType> BattalionTypes { get; set; }
            public IDictionary<BattalionId, String> BattalionNames => GsView.BattalionNames;

            public TokenId? CommandToken { get; set; }
            public TokenId LastPostedToken { get; set; }

            public String CommandTokenString =>
                    CommandToken.HasValue ? CommandToken.Value.ToString() : "{null}";

            public Dictionary<ProvinceId, OneProvinceAvailableCommands>
                    AvailableCommandsByProvince {
                        get; set;
                    }
            public int SuggestedProvinceId { get; set; }

            public List<ProvinceId> ProvincesForFaction(FactionId factionId) {
                return Provinces
                        .Where(p => p.Value.RulingFactionId.HasValue &&
                                    p.Value.RulingFactionId.Value == factionId)
                        .Select(kv => kv.Key)
                        .ToList();
            }

            public List<ChronicleEntry> ChronicleEntries { get; set; }

            public GameType GameType => GsView.GameType;

            public EagleMapInfo EagleMap => GsView.EagleMap;

            public List<AvailableCommand> AvailableCommandsForProvince(ProvinceId pid) {
                if (AvailableCommandsByProvince.TryGetValue(pid, out var cmds)) {
                    return cmds.Commands.ToList();
                }
                return new List<AvailableCommand>();
            }

            // Thread-safe: accessed from heartbeat timer thread via ShardokViewStatuses
            public ConcurrentDictionary<String, ShardokGameModel> ShardokGameModels { get; set; }

            public FactionView MaybeDestroyedFaction(FactionId factionId) {
                if (ActiveFactions.TryGetValue(factionId, out var factionView)) {
                    return factionView;
                }
                return DestroyedFactions[factionId];
            }
            public string FactionName(FactionId factionId) {
                return DisplayNames.ResolvedFactionString(MaybeDestroyedFaction(factionId), this);
            }

            public void SetDynamicFactionText(
                    TMP_Text textComponent,
                    FactionId factionId,
                    DynamicFactionTextUpdater textUpdater,
                    string template = null,
                    string fallbackText = null) {
                var faction = MaybeDestroyedFaction(factionId);
                textUpdater.SetFactionText(textComponent, faction, this, template, fallbackText);
            }
        }

        private ConcreteGameModel _currentModel;

        public GameModelUpdater(
                GameId eagleGameId,
                FactionId? factionId,
                PersistentClientConnection pers) {
            GameId = eagleGameId;
            PersistentConnection = pers;

            _currentModel.LastPostedToken = -1;
            _currentModel.PlayerId = factionId;
            _currentModel.AvailableCommandsByProvince =
                    new Dictionary<ProvinceId, OneProvinceAvailableCommands>();

            _currentModel.GsView = new GameStateView();
            _currentModel.ShardokGameModels =
                    new ConcurrentDictionary<ShardokGameId, ShardokGameModel>();

            _currentModel.BattalionTypes = new Dictionary<BattalionTypeId, BattalionType>();
        }

        private ShardokGameModel MakeGameModel(ShardokGameId shardokGameId) {
            var battleView = _currentModel.ShardokBattles.FirstOrDefault(
                    b => b.ShardokGameId == shardokGameId);

            if (battleView == null) {
                // Battle was removed (e.g., it ended) before we could create the model.
                // This is expected when Eagle's RemovedBattleIds update arrives before a
                // pending Shardok update - the UI already shows "Exit Battle" via
                // MarkBattleEnded(), so we just skip this stale update.
                return null;
            }

            PlayerId playerId = battleView.MyPlayerId ?? -1;

            var map = HexMapStorage.Instance.WithName(battleView.MapName);

            var players = battleView.PlayerInfos
                                  .Select(pi => new ShardokGameModel.PlayerWithHostility {
                                      playerId = pi.PlayerId,
                                      factionName = _currentModel.FactionName(pi.EagleFactionId),
                                      hostility = pi.Hostility,
                                      isDefender = pi.IsDefender
                                  })
                                  .ToList();

            var defenderProvince = battleView.DefenderProvince;

            // Create the model as soon as we know about the game, store it in a dictionary or
            // something, and then just set up the game when we go to the canvas
            var shardokModel = new ShardokGameModel(
                    eagleGameId: GameId,
                    shardokGameId: battleView.ShardokGameId,
                    playerId: playerId,
                    persistentClientConnection: PersistentConnection,
                    map: map,
                    locationName: _currentModel.Provinces[defenderProvince].Name,
                    month: _currentModel.CurrentDate.Month,
                    players: players,
                    heroNameTextIds: _currentModel.Heroes.ToDictionary(
                            kv => kv.Key,
                            kv => kv.Value.NameTextId),
                    heroImages: _currentModel.Heroes.ToDictionary(
                            kv => kv.Key,
                            kv => kv.Value.ImagePath),
                    battalionNames: _currentModel.BattalionNames.ToDictionary(
                            kv => kv.Key,
                            kv => kv.Value),
                    heroFallbackLookup: hid => {
                        if (_currentModel.Heroes.TryGetValue(hid, out var hero)) {
                            return (hero.NameTextId, hero.ImagePath);
                        }
                        return null;
                    });

            return shardokModel;
        }

        public void ReceiveGameUpdate(GameUpdate updateItem) {
            if (updateItem.StartingState != null) { HandleStartingState(updateItem.StartingState); }

            // Store server-reported game status for UI (available on any update type)
            if (updateItem.ServerGameStatus != null) {
                ServerStatus = updateItem.ServerGameStatus;
                _connectionLogger.LogLine(
                        $"[UPDATE] ServerGameStatus updated: {ServerStatus.Status}");
            }

            switch (updateItem.GameUpdateDetailsCase) {
                case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
                    // Clear processing state - we received a response from the server
                    _commandSubmittedTime = null;

                    // Note: _lastUnfilteredResultCount is updated on the gRPC thread in
                    // UpdateResultCounts() before enqueueing. We don't update it here to avoid
                    // race conditions where a backlogged MainQueue update overwrites a newer count.

                    var hasResults = updateItem.ActionResultResponse.ActionResultViews.Any();
                    var hasCommands = updateItem.ActionResultResponse.AvailableCommands != null;
                    var incomingToken =
                            hasCommands ? updateItem.ActionResultResponse.AvailableCommands.Token
                                        : -1;
                    var tokenMatches = hasCommands && incomingToken == _currentModel.CommandToken;

                    _connectionLogger.LogLine(
                            $"[UPDATE] hasResults={hasResults}, hasCommands={hasCommands}, " +
                            $"incomingToken={incomingToken}, currentToken={_currentModel.CommandTokenString}, " +
                            $"tokenMatches={tokenMatches}");

                    if (hasResults || !hasCommands || !tokenMatches) {
                        HandleUpdates(updateItem.ActionResultResponse.ActionResultViews.ToList());
                        HandleAvailableCommands(updateItem.ActionResultResponse.AvailableCommands);

                        if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
                        _connectionLogger.LogLine(
                                "[UPDATE] Processed update and invoked UpdateAction");
                    } else {
                        _connectionLogger.LogLine(
                                "[UPDATE] SKIPPED - no results, has commands, token matches");
                    }

                    // Check for turn state mismatch after processing update
                    CheckForTurnMismatch();

                    break;

                case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
                    var specResponse = updateItem.ShardokActionResultResponse.ShardokGameResponses;

                    // Track if we created new models that require re-subscription
                    bool createdNewModels = false;

                    foreach (var oneResponse in specResponse) {
                        if (!_currentModel.ShardokGameModels.TryGetValue(
                                    oneResponse.ShardokGameId,
                                    out var shardokGameModel)) {
                            shardokGameModel =
                                    MakeGameModel(shardokGameId: oneResponse.ShardokGameId);

                            // Battle may have ended before we could create the model - remove
                            // any stale reference and skip this update
                            if (shardokGameModel == null) {
                                _currentModel.ShardokGameModels.TryRemove(
                                        oneResponse.ShardokGameId,
                                        out _);
                                _shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
                                continue;
                            }

                            // New model created - we need to re-subscribe so the server
                            // knows to keep sending updates for this game
                            createdNewModels = true;
                            _connectionLogger.LogLine(
                                    $"[SHARDOK] Created new model for {oneResponse.ShardokGameId}, will re-subscribe");
                        }

                        var updatesOk = shardokGameModel.HandleUpdates(
                                oneResponse.ActionResultViews,
                                oneResponse.NewResultViewCount);

                        if (!updatesOk) {
                            // Missing results - mark for resync and clear history
                            MarkShardokForResync(oneResponse.ShardokGameId);
                            shardokGameModel.History.Clear();
                            continue;
                        }

                        shardokGameModel.HandleAvailableCommands(oneResponse.AvailableCommands);

                        // Clear resync flags after successfully receiving updates
                        ClearShardokResyncFlag(oneResponse.ShardokGameId);
                        shardokGameModel.SkipAutoPlacement = false;

                        _currentModel.ShardokGameModels[oneResponse.ShardokGameId] =
                                shardokGameModel;
                    }

                    // If we created new models, re-subscribe so the server knows to keep
                    // sending updates for these games. The original subscription didn't
                    // include them because ShardokGameModels was empty at subscribe time.
                    if (createdNewModels) {
                        _connectionLogger.LogLine(
                                "[SHARDOK] Re-subscribing to include newly created Shardok models");
                        _ = StartListeningForUpdates();
                    }

                    if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
                    break;

                case GameUpdate.GameUpdateDetailsOneofCase.ShardokBattleResetResponse:
                    var resetGameId = updateItem.ShardokBattleResetResponse.ShardokGameId;
                    _connectionLogger.LogLine($"[SHARDOK] Battle reset for {resetGameId}");

                    if (_currentModel.ShardokGameModels.TryGetValue(
                                resetGameId,
                                out var resetModel)) {
                        resetModel.ResetForNewBattle();
                    }
                    _shardokResultCounts.TryRemove(resetGameId, out _);
                    if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
                    break;

                case GameUpdate.GameUpdateDetailsOneofCase.StreamingTextResponse:
                    HandleNewStreamingText(updateItem.StreamingTextResponse);

                    if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
                    break;

                case GameUpdate.GameUpdateDetailsOneofCase.BattleProgressResponse:
                    _currentModel.BattleDay = updateItem.BattleProgressResponse.CurrentDay;
                    ApplyBattleProgressUpdate(updateItem.BattleProgressResponse.UpdatedBattles);
                    if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
                    break;

                case GameUpdate.GameUpdateDetailsOneofCase.ErrorResponse:
                    ErrorHandler.HandleLog(
                            $"Caught server-side error handling command {updateItem.ErrorResponse.SelectedCommand}:\n{updateItem.ErrorResponse.ErrorString}",
                            "",
                            LogType.Error);
                    break;
            }
        }

        /// <summary>
        /// Update result counts immediately when an update is received from the server.
        /// Called from the gRPC thread BEFORE enqueueing to MainQueue, to ensure
        /// reconnects use accurate counts even when MainQueue is blocked (e.g., backgrounded).
        /// </summary>
        public void UpdateResultCounts(GameUpdate update) {
            switch (update.GameUpdateDetailsCase) {
                case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
                    var newCount = update.ActionResultResponse.UnfilteredResultCountAfter;
                    lock (_resultCountLock) {
                        var oldCount = _lastUnfilteredResultCount;
                        _lastUnfilteredResultCount = newCount;
                        if (newCount != oldCount) {
                            _connectionLogger.LogLine(
                                    $"[RESULT_COUNT] Updated count {oldCount} -> {newCount}");
                        }
                    }
                    break;

                case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
                    foreach (var response in update.ShardokActionResultResponse
                                     .ShardokGameResponses) {
                        _shardokResultCounts[response.ShardokGameId] = response.NewResultViewCount;
                    }
                    break;
            }
        }

        /// <summary>
        /// Subscribe to game updates. Returns true if subscription was acknowledged by server.
        /// </summary>
        public async Task<bool> StartListeningForUpdates() {
            return await PersistentConnection.Subscribe(this);
        }

        public void StopListeningForUpdates() { PersistentConnection.Unsubscribe(this); }

        /// <summary>
        /// Mark a Shardok game for full state resync on next connection.
        /// Used after connection drops to ensure state consistency.
        /// </summary>
        public void MarkShardokForResync(string shardokGameId) {
            _shardokNeedsResync[shardokGameId] = true;
            _connectionLogger.LogLine(
                    $"[RESYNC] Marked Shardok game {shardokGameId} for full state resync");
        }

        /// <summary>
        /// Clear resync flag after successfully receiving full state.
        /// </summary>
        public void ClearShardokResyncFlag(string shardokGameId) {
            if (_shardokNeedsResync.TryRemove(shardokGameId, out _)) {
                _connectionLogger.LogLine(
                        $"[RESYNC] Cleared resync flag for Shardok game {shardokGameId}");
            }
        }

        /// <summary>
        /// Mark all active Shardok games for resync (called on disconnect).
        /// </summary>
        public void MarkAllShardokForResync() {
            foreach (var shardokGameId in _currentModel.ShardokGameModels.Keys) {
                MarkShardokForResync(shardokGameId);
            }
        }

        public Task PostCommand(ProvinceId provinceId, SelectedCommand command) {
            _connectionLogger.LogLine(
                    $"Posting command with token {_currentModel.CommandTokenString}");
            if (_currentModel.CommandToken is long token) {
                _currentModel.AvailableCommandsByProvince.Clear();
                _currentModel.CommandToken = null;
                _currentModel.LastPostedToken = token;
                _commandSubmittedTime = DateTime.UtcNow;
                return PersistentConnection.PostEagleCommand(
                        gameId: GameId,
                        token: token,
                        provinceId: provinceId,
                        command: command);
            }

            throw new ArgumentException("No command token available to post command");
        }

        private void ApplyBattleProgressUpdate(RepeatedField<ShardokBattleView> updatedBattles) {
            foreach (ShardokBattleView ub in updatedBattles) {
                for (int i = 0; i < _currentModel.ShardokBattles.Count; i++) {
                    if (_currentModel.ShardokBattles[i].ShardokGameId == ub.ShardokGameId) {
                        _currentModel.ShardokBattles[i] = ub;
                        break;
                    }
                }
            }
        }

        private void HandleStartingState(GameStateView startingState) {
            var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff");
            _connectionLogger.LogLine(
                    $"[STATE_RESYNC] timestamp={timestamp} round={startingState.CurrentRoundId} factions={startingState.Factions.Count} heroes={startingState.Heroes.Count}");

            // Clear all stale state before applying new starting state.
            // This handles rewind/reset scenarios where the server sends an earlier state.
            // NOTE: We intentionally preserve LastPostedToken - if we have a pending command
            // from before the reconnect, we need to detect when the server sends us the same
            // token so we can skip loading those stale commands.
            _currentModel.ShardokGameModels.Clear();
            _shardokResultCounts.Clear();
            _shardokNeedsResync.Clear();
            _currentModel.CommandToken = null;
            _currentModel.AvailableCommandsByProvince.Clear();
            _commandSubmittedTime = null;
            _turnMismatchDetectedTime = null;

            _currentModel.GsView = startingState;
            _currentModel.BattalionTypes =
                    startingState.BattalionTypes.ToDictionary(bt => bt.TypeId, bt => bt);
            // Don't overwrite existing chronicle entries with an empty list.
            // Chronicle entries are historical records that accumulate over time.
            // A stale StartingState (e.g., from early in the game before chronicles existed)
            // arriving after a current state could otherwise wipe out existing entries.
            if (startingState.ChronicleEntries.Any() || _currentModel.ChronicleEntries == null) {
                _currentModel.ChronicleEntries = startingState.ChronicleEntries.ToList();
            }

            // For any outstanding battles in the new state, create models and mark for resync.
            bool needsResubscribe = false;
            foreach (var battle in _currentModel.ShardokBattles) {
                if (!_currentModel.ShardokGameModels.ContainsKey(battle.ShardokGameId)) {
                    var model = MakeGameModel(battle.ShardokGameId);
                    if (model != null) {
                        model.SkipAutoPlacement = true;
                        _currentModel.ShardokGameModels[battle.ShardokGameId] = model;
                        MarkShardokForResync(battle.ShardokGameId);
                        _connectionLogger.LogLine(
                                $"[STATE_RESYNC] Created ShardokGameModel for outstanding battle {battle.ShardokGameId}");
                        needsResubscribe = true;
                    }
                }
            }

            // If we created new ShardokGameModels, re-subscribe to request their full state.
            // The original subscribe didn't include these battles since we didn't know about them
            // yet.
            if (needsResubscribe) {
                _connectionLogger.LogLine(
                        "[STATE_RESYNC] Re-subscribing to request full Shardok state for new battles");
                _ = StartListeningForUpdates();
            }
        }

        private void HandleUpdates(List<ActionResultView> results) {
            foreach (ActionResultView entry in results) { HandleNewHistoryEntry(entry); }
        }

        private void HandleAvailableCommands(AvailableCommands availableCommands) {
            if (availableCommands == null || availableCommands.CommandsByProvince.Count == 0) {
                if (_currentModel.CommandToken.HasValue) {
                    _connectionLogger.LogLine(
                            $"Token was {_currentModel.CommandTokenString}, but nulling out since there's no available commands");
                }
                _currentModel.CommandToken = null;
                _currentModel.AvailableCommandsByProvince.Clear();
                return;
            }

            if (availableCommands.Token < _currentModel.LastPostedToken) {
                throw new ArgumentException(
                        $"Received token {availableCommands.Token} that is less than last posted token {_currentModel.LastPostedToken}");
            }

            if (availableCommands.Token == _currentModel.LastPostedToken) {
                // We're receiving a token for a command we already posted. This can happen in two
                // scenarios:
                // 1. Race condition: receiving a game update from another source before our command
                //    was processed.
                // 2. Our POST failed (e.g., due to disconnect) and the server is re-sending the
                // same
                //    token after reconnection.
                //
                // In case 1, if we already have commands loaded, that's unexpected - throw.
                // In case 2, we have no commands and should accept the server's commands rather
                // than skipping, otherwise we'll be stuck with no commands.

                if (_currentModel.CommandToken != null ||
                    _currentModel.AvailableCommandsByProvince.Count > 0) {
                    throw new ArgumentException(
                            $"Received token {availableCommands.Token} that matches last posted token, but we already have commands: {_currentModel.AvailableCommandsByProvince}");
                }

                // Don't skip - accept the commands. If we successfully posted before, the server
                // will return BAD_TOKEN when we try to post again. But if our POST failed, we need
                // these commands to proceed.
                _connectionLogger.LogLine(
                        $"Received token {availableCommands.Token} that matches last posted token; accepting commands (POST may have failed)");
                _currentModel.LastPostedToken = -1;  // Clear to avoid future skips
            }

            _connectionLogger.LogLine(
                    $"Updating token from {_currentModel.CommandTokenString} to {availableCommands.Token}");
            if (_currentModel.CommandToken.HasValue &&
                _currentModel.CommandToken.Value != availableCommands.Token) {
                _connectionLogger.LogLine("How are we updating an existing token?");
                _connectionLogger.LogLine(
                        $"Existing commands: {_currentModel.AvailableCommandsByProvince}");
                _connectionLogger.LogLine($"New commands: {availableCommands.CommandsByProvince}");
            } else if (
                    _currentModel.CommandToken.HasValue &&
                    _currentModel.AvailableCommandsByProvince.Count > 0) {
                // If we're in here, we already have commands and the tokens match, so there's
                // nothing to update.
                return;
            }

            _currentModel.CommandToken = availableCommands.Token;

            // Check if there's a pending command with this token waiting to be posted.
            // If so, don't load the commands - they're stale (we already acted on them).
            // The pending command will be posted by TryPendingCommands, and we'll get
            // fresh commands after it's processed.
            if (HasPendingCommandWithToken != null &&
                HasPendingCommandWithToken(availableCommands.Token)) {
                _connectionLogger.LogLine(
                        $"Token {availableCommands.Token} matches pending command; " +
                        "skipping command load, pending command will be posted");
                _currentModel.AvailableCommandsByProvince.Clear();
                return;
            }

            // does this not clear existing UX if there's a token replacement?
            _currentModel.AvailableCommandsByProvince =
                    new Dictionary<UnitId, OneProvinceAvailableCommands>(
                            availableCommands.CommandsByProvince);
            _currentModel.SuggestedProvinceId = availableCommands.SuggestedProvinceId;
        }

        private void UpdateHero(HeroViewDiff ch) {
            var hero = _currentModel.Heroes[ch.Id];

            if (ch.NewNameTextId is {} newNameTextId) hero.NameTextId = newNameTextId;
            if (ch.ClearFactionId) hero.FactionId = null;
            if (ch.NewFactionId is {} newFactionId) hero.FactionId = newFactionId;
            if (ch.NewVigor is {} newVigor) hero.Vigor = newVigor;
            if (ch.NewLoyalty is {} newLoyalty) hero.Loyalty = newLoyalty;
            if (ch.BecameFactionLeader is true) hero.Loyalty = null;
            if (ch.NewProfession is {} newProfession) hero.Profession = newProfession.Profession;

            if (ch.NewStrength is {} newStrength) hero.Strength = newStrength;
            if (ch.NewStrengthXp is {} newStrengthXp) hero.StrengthXp = newStrengthXp;
            if (ch.NewAgility is {} newAgility) hero.Agility = newAgility;
            if (ch.NewAgilityXp is {} newAgilityXp) hero.AgilityXp = newAgilityXp;
            if (ch.NewCharisma is {} newCharisma) hero.Charisma = newCharisma;
            if (ch.NewCharismaXp is {} newCharismaXp) hero.CharismaXp = newCharismaXp;
            if (ch.NewWisdom is {} newWisdom) hero.Wisdom = newWisdom;
            if (ch.NewWisdomXp is {} newWisdomXp) hero.WisdomXp = newWisdomXp;

            if (ch.NewConstitution is {} newConstitution) hero.Constitution = newConstitution;
            if (ch.NewConstitutionXp is {} newConstitutionXp)
                hero.ConstitutionXp = newConstitutionXp;

            if (ch.NewIntegrity is {} newIntegrity) hero.Integrity = newIntegrity;
            if (ch.NewAmbition is {} newAmbition) hero.Ambition = newAmbition;
            if (ch.NewGregariousness is {} newGregariousness)
                hero.Gregariousness = newGregariousness;
            if (ch.NewBravery is {} newBravery) hero.Bravery = newBravery;

            if (ch.NewArcheryCapable is {} newArcheryValue) hero.ArcheryCapable = newArcheryValue;
            if (ch.NewStartFireCapable is {} newStartFire) hero.StartFireCapable = newStartFire;

            if (ch.NewImagePath is {} newImagePath) hero.ImagePath = newImagePath;

            if (ch.NewSortKeys.Any()) {
                hero.SortKeys.Clear();
                hero.SortKeys.AddRange(ch.NewSortKeys);
            }

            if (ch.NewBackstoryTextId is {} newBackstoryTextId)
                hero.BackstoryTextId = newBackstoryTextId;
            if (ch.NewPronounGender is {} newPronounGender)
                hero.PronounGender = newPronounGender.PronounGender;
        }

        private void UpdateFaction(FactionViewDiff cf) {
            var faction = _currentModel.ActiveFactions[cf.Id];

            if (cf.NewPrestige is int prestige) faction.Prestige = prestige;
            if (cf.NewFactionHeadId is HeroId hid) faction.FactionHeadId = hid;
            foreach (var addedLeader in cf.AddedLeaders) { faction.Leaders.Add(addedLeader); }
            foreach (var removedLeader in cf.RemovedLeaders) {
                faction.Leaders.Remove(removedLeader);
            }

            var relationshipMap = faction.FactionRelationships.ToDictionary(
                    fac => fac.TargetFactionId,
                    fac => fac);
            foreach (var cr in cf.ChangedFactionRelationships) {
                relationshipMap[cr.TargetFactionId] = cr;
            }
            faction.FactionRelationships.Clear();
            faction.FactionRelationships.Add(relationshipMap.Values);

            faction.IncomingDiplomacyOffers.Add(cf.AddedIncomingDiplomacyOffers);
            foreach (var removedOffer in cf.RemovedIncomingDiplomacyOffers) {
                faction.IncomingDiplomacyOffers.Remove(removedOffer);
            }

            faction.FocusProvinceId = cf.NewFocusProvinceId;
        }

        private void UpdateProvince(ProvinceViewDiff cp) {
            if (!_currentModel.Provinces.TryGetValue(cp.Id, out var province))
                province = new ProvinceView();

            if (cp.ClearRulingFactionId) { province.RulingFactionId = null; }
            if (cp.NewRulingFactionId is {} newRulingFactionId)
                province.RulingFactionId = newRulingFactionId;

            if (cp.ClearFullInfo) { province.FullInfo = null; }

            if (cp.EventsReplacement != null) {
                province.KnownEvents.Clear();
                province.KnownEvents.Add(cp.EventsReplacement.NewEvents);
            }

            province.KnownIncomingArmies.Clear();
            province.KnownIncomingArmies.AddRange(cp.NewIncomingArmies);

            if (cp.NewFullInfo != null) {
                province.FullInfo ??= new FullProvinceInfo();

                var nfi = cp.NewFullInfo;

                if (nfi.ClearRulingHero) province.FullInfo.RulingHeroId = null;
                if (nfi.NewRulingHeroId is {} newRulingHeroId)
                    province.FullInfo.RulingHeroId = newRulingHeroId;

                if (nfi.OrderedRulingFactionHeroIds != null) {
                    province.FullInfo.RulingFactionHeroIds.Clear();
                    province.FullInfo.RulingFactionHeroIds.Add(
                            nfi.OrderedRulingFactionHeroIds.RulingFactionHeroIds);
                }

                var removedBattalions = province.FullInfo.Battalions
                                                .Where(b => nfi.RemovedBattalionIds.Contains(b.Id))
                                                .ToList();
                foreach (var removed in removedBattalions) {
                    province.FullInfo.Battalions.Remove(removed);
                }
                province.FullInfo.Battalions.Add(nfi.AddedBattalions);

                foreach (var bn in nfi.AddedBattalions) {
                    _currentModel.BattalionNames[bn.Id] = bn.Name;
                }

                if (nfi.ClearDefendingArmy) province.FullInfo.DefendingArmy = null;
                if (nfi.NewDefendingArmy != null)
                    province.FullInfo.DefendingArmy = nfi.NewDefendingArmy;

                if (nfi.Gold is int newGold) province.FullInfo.Gold = newGold;
                if (nfi.Food is {} newFood) province.FullInfo.Food = newFood;

                if (nfi.Economy is {} newEconomy) province.FullInfo.Economy = newEconomy;
                if (nfi.Agriculture is {} newAgriculture)
                    province.FullInfo.Agriculture = newAgriculture;
                if (nfi.Infrastructure is {} newInfrastructure)
                    province.FullInfo.Infrastructure = newInfrastructure;

                if (nfi.EconomyDevastation is {} newEconomyDevastation)
                    province.FullInfo.EconomyDevastation = newEconomyDevastation;
                if (nfi.AgricultureDevastation is {} newAgricultureDevastation)
                    province.FullInfo.AgricultureDevastation = newAgricultureDevastation;
                if (nfi.InfrastructureDevastation is {} newInfrastructureDevastation)
                    province.FullInfo.InfrastructureDevastation = newInfrastructureDevastation;

                if (nfi.NewPriceIndex is double newPriceIndex)
                    province.FullInfo.PriceIndex = newPriceIndex;

                if (nfi.Support != null) province.FullInfo.Support = nfi.Support;

                if (nfi.SetRulerIsTraveling is {} newRulerIsTraveling)
                    province.FullInfo.RulerIsTraveling = newRulerIsTraveling;

                if (nfi.NewProvinceOrders != null)
                    province.FullInfo.ProvinceOrders = nfi.NewProvinceOrders.Orders;

                if (nfi.FoodConsumption is {} newFoodConsumption)
                    province.FullInfo.FoodConsumption = newFoodConsumption;

                if (nfi.GoldCap is {} newGoldCap) province.FullInfo.GoldCap = newGoldCap;
                if (nfi.FoodCap is {} newFoodCap) province.FullInfo.FoodCap = newFoodCap;

                if (nfi.HexMapName != null) province.FullInfo.HexMapName = nfi.HexMapName;

                if (nfi.CastleCount != null) province.FullInfo.CastleCount = nfi.CastleCount.Value;
                if (nfi.HeroCap is int newCap) province.FullInfo.HeroCap = newCap;

                foreach (HeroId removed in nfi.RemovedUnaffiliatedHeroIds) {
                    for (int i = 0; i < province.FullInfo.UnaffiliatedHeroes.Count; i++) {
                        if (province.FullInfo.UnaffiliatedHeroes[i].HeroId == removed) {
                            province.FullInfo.UnaffiliatedHeroes.RemoveAt(i);
                            break;
                        }
                    }
                }
                foreach (var added in nfi.AddedUnaffiliatedHeroes) {
                    if (province.FullInfo.UnaffiliatedHeroes.Any(uh => uh.HeroId == added.HeroId)) {
                        throw new InvalidOperationException(
                                $"Duplicate unaffiliated hero {added.HeroId} in province {cp.Id}");
                    }
                    province.FullInfo.UnaffiliatedHeroes.Add(added);
                }
            }

            if (cp.AsOf != null) { province.AsOf = cp.AsOf; }
        }

        private void UpdateChronicleEntry(ChronicleEntry newEntry) {
            _currentModel.ChronicleEntries ??= new List<ChronicleEntry>();

            var existingIndex =
                    _currentModel.ChronicleEntries.FindIndex(ce => ce.Date.Equals(newEntry.Date));

            if (existingIndex == -1) {
                _currentModel.ChronicleEntries.Add(newEntry);
            } else {
                _currentModel.ChronicleEntries[existingIndex] = newEntry;
            }

            _currentModel.ChronicleEntries =
                    _currentModel.ChronicleEntries.OrderBy(ce => ce.Date.Year)
                            .ThenBy(ce => ce.Date.Month)
                            .ToList();
        }

        private void ApplyGameStateViewDiff(GameStateViewDiff entry) {
            if (entry == null) return;

            if (entry.NewRoundId is {} newRoundId) {
                _currentModel.GsView.CurrentRoundId = newRoundId;
            }
            if (entry.NewPhase is {} newPhase) {
                _currentModel.GsView.CurrentPhase = newPhase.Value;
            }

            if (entry.NewDate is {} newDate) {
                _currentModel.GsView.CurrentDate = newDate;
                _currentModel.ShardokGameModels.Clear();
            }

            foreach (ProvinceView province in entry.NewProvinces)
                _currentModel.Provinces[province.Id] = province;

            foreach (ProvinceViewDiff cp in entry.ChangedProvinces) UpdateProvince(cp);

            foreach (HeroView newHero in entry.NewHeroes)
                _currentModel.Heroes[newHero.Id] = newHero;

            foreach (HeroViewDiff ch in entry.ChangedHeroes) UpdateHero(ch);

            foreach (FactionView newFaction in entry.NewFactions)
                _currentModel.ActiveFactions[newFaction.Id] = newFaction;

            foreach (FactionViewDiff cf in entry.ChangedFactions) UpdateFaction(cf);

            foreach (FactionId fid in entry.RemovedFactionIds) {
                _currentModel.DestroyedFactions[fid] = new FactionView {
                    Id = fid,
                    Name = _currentModel.ActiveFactions[fid].Name,
                    FactionHeadId = _currentModel.ActiveFactions[fid].FactionHeadId
                };
                _currentModel.ActiveFactions.Remove(fid);
            }

            foreach (ShardokBattleView bv in entry.NewBattles) {
                _currentModel.ShardokBattles.Add(bv);
                _currentModel.BattleDay = 0;
            }

            foreach (string rb in entry.RemovedBattleIds) {
                // If there's an active ShardokGameModel for this battle, mark it as ended
                // so the UI knows to return to Eagle. This handles the race condition where
                // the Eagle update removing the battle arrives before the Shardok Victory update.
                if (_currentModel.ShardokGameModels.TryGetValue(rb, out var sgm)) {
                    sgm.MarkBattleEnded("Battle has ended.");
                    _currentModel.ShardokGameModels.TryRemove(rb, out _);
                }
                _shardokResultCounts.TryRemove(rb, out _);

                for (int i = 0; i < _currentModel.ShardokBattles.Count; i++) {
                    if (_currentModel.ShardokBattles[i].ShardokGameId == rb) {
                        _currentModel.ShardokBattles.RemoveAt(i);
                        break;
                    }
                }
            }

            ApplyBattleProgressUpdate(entry.UpdatedBattles);

            foreach (BattalionType bt in entry.NewBattalionTypes) {
                _currentModel.BattalionTypes[bt.TypeId] = bt;
            }

            foreach (var bn in entry.NewBattalionNames) {
                _currentModel.BattalionNames[bn.Key] = bn.Value;
            }

            foreach (var ce in entry.UpdatedChronicleEntries) { UpdateChronicleEntry(ce); }

            if (entry.NewGameType != GameType.UnknownGameType) {
                _currentModel.GsView.GameType = entry.NewGameType;
            }
        }

        private void HandleNewHistoryEntry(ActionResultView arv) {
            _currentModel.HistoryCount++;

            MaybeSendNotification(arv);
            TutorialManager.Instance?.TriggerRegistry?.OnStrategicActionResult(arv);
            ApplyGameStateViewDiff(arv.GameStateDiff);
            ActionResultAction?.Invoke(arv);
        }

        private void HandleNewStreamingText(StreamingTextResponse str) {
            if (str == null) return;

            ClientTextProvider.Provider.HandleNewStreamingText(
                    str.LlmIdentifier,
                    str.NewText,
                    str.StartingByteCount,
                    str.Completed);
        }

        private void MaybeSendNotification(ActionResultView entry) {
            if (!PlayerId.HasValue) { return; }

            foreach (var result in NotificationDispatcher.GenerateNotifications(
                             entry,
                             _currentModel)) {
                Notify(result);
            }
        }

#region IGameStateProvider implementation

        /// <summary>Server-reported game status from the last ActionResultResponse.</summary>
        public ServerGameStatus ServerStatus { get; private set; }

        /// <summary>
        /// True if a command was submitted and we're awaiting response.
        /// Only returns true if processing for > 500ms to avoid flashing.
        /// </summary>
        public bool IsProcessingCommand =>
                _commandSubmittedTime.HasValue &&
                (DateTime.UtcNow - _commandSubmittedTime.Value).TotalMilliseconds >
                        ProcessingDisplayDelayMs;

#endregion

#region Turn Mismatch Detection

        /// <summary>
        /// Checks for turn state mismatch: server reports YourTurn but client has no commands.
        /// If mismatch persists beyond the grace period, logs an error and triggers reconnection.
        /// </summary>
        private void CheckForTurnMismatch() {
            // Only check if we have server status
            if (ServerStatus == null) {
                _turnMismatchDetectedTime = null;
                return;
            }

            bool serverSaysYourTurn = ServerStatus.Status == ServerGameStatus.Types.Status.YourTurn;
            bool clientHasNoCommands = _currentModel.AvailableCommandsByProvince.Count == 0 &&
                                       !_currentModel.CommandToken.HasValue;
            bool isProcessingCommand = _commandSubmittedTime.HasValue;

            // Mismatch: server says it's our turn but we have no commands to act on
            // Exclude cases where we just submitted a command (waiting for response)
            bool hasMismatch = serverSaysYourTurn && clientHasNoCommands && !isProcessingCommand;

            if (!hasMismatch) {
                // No mismatch - reset detection state
                _turnMismatchDetectedTime = null;
                return;
            }

            // Mismatch detected - track when it started
            if (!_turnMismatchDetectedTime.HasValue) {
                _turnMismatchDetectedTime = DateTime.UtcNow;
                _connectionLogger.LogLine(
                        "[TURN_MISMATCH] Detected: server says YourTurn but client has no commands. " +
                        "Waiting for grace period...");
                return;
            }

            // Check if grace period has elapsed
            var elapsedSeconds = (DateTime.UtcNow - _turnMismatchDetectedTime.Value).TotalSeconds;
            if (elapsedSeconds < TurnMismatchGracePeriodSeconds) { return; }

            // Grace period exceeded - this is a real mismatch, trigger recovery
            _connectionLogger.LogLine(
                    $"[TURN_MISMATCH] ERROR: Turn mismatch persisted for {elapsedSeconds:F1}s. " +
                    $"ServerStatus={ServerStatus.Status}, " +
                    $"AvailableCommands={_currentModel.AvailableCommandsByProvince.Count}, " +
                    $"CommandToken={_currentModel.CommandTokenString}, " +
                    $"LastPostedToken={_currentModel.LastPostedToken}. " +
                    "Triggering reconnection to recover state.");

            // Throw exception for ErrorHandler to capture (provides stack trace for debugging)
            var errorMessage =
                    $"Turn mismatch detected: server reports YourTurn but client has no commands " +
                    $"after {elapsedSeconds:F1}s. Reconnecting to recover state.";
            ErrorHandler?.Add(new InvalidOperationException(errorMessage));

            // Reset detection state and trigger reconnection
            _turnMismatchDetectedTime = null;
            PersistentConnection.ForceReconnect();
        }

#endregion
    }
}