using UnityEngine;
using GeoSus.Client;
using Subsystems;
using System.Collections;
using System;
using TMPro;
using UnityEngine.SceneManagement;
public class GameManager : MonoBehaviour
{
    // Singleton
    public static GameManager Instance { get; private set; }

    [Header("Subsystems")]
    public GameManager_Network networkSubsystem;
    public GameManager_UI uiSubsystem;
    public GameManager_Map mapSubsystem;
    public GameManager_Input inputSubsystem;
    public GameManager_Tasks taskSubsystem;

    public GameClient gameClient;

    [Header("Player Info")]
    public string displayName;

    [Header("Scene Management")]
    [SerializeField] public string firstMenuScene = "main menu asi idk lol";

    [Header("UI Elements (Client.unity)")]
    // Canvas names in Client.unity — found at runtime in OnSceneLoaded
    private const string CanvasNameJoinCreate  = "LobbySelector";
    private const string CanvasNameInLobby     = "InLobby";
    private const string CanvasNameLoading     = "LoadingScreen";
    private const string CanvasNameGame        = "InGame";

    [Header("Map")]
    // MapCenterPoint and Player are in Client.unity — wired at runtime in OnSceneLoaded.
    // buildingSettings/pathwaySettings/areaSettings must be assigned in SampleScene Inspector.
    public BuildingSettings buildingSettings;
    public PathwaySettings pathwaySettings;
    public AreaSettings areaSettings;

    [Header("Lobby Settings")]
    public double pendingRadius = 500;
    public int pendingImpostorCount = 1;
    public int pendingTaskCount = 5;
    /// <summary>
    /// P13b/c: full settings overrides accumulated by HostLobbyUI before the
    /// host taps "Create". Null = host didn't change anything beyond the three
    /// flat fields above; server falls through to its current defaults for
    /// every field. Each field is independently nullable so the host can
    /// opt into changing only what they care about.
    /// </summary>
    public GameSettingsOverrides pendingSettings;

    [Header("Task Minigames (round-robin)")]
    // Names MUST match the scene file names in Assets/Scenes (case-sensitive)
    // and each one MUST be enabled in EditorBuildSettings, or LoadSceneAsync
    // will silently fail and the task button will appear dead.
    [SerializeField] public string[] minigameScenes = {
        "MiniGame-Kabely V10",
        "MiniGame-insertkeys",
        "MiniGame-FlappyBird",
        "MiniGame-ThrowInHole",
        "MiniGame-Satelit"
    };

    [Header("Debug")]
    public bool testMode = false;
    /// <summary>
    /// When true, draw a small GPS status banner across the top of every
    /// screen. Useful for diagnosing why CreateLobby is blocked or why a
    /// joiner's position isn't updating - failures otherwise only show up
    /// in logcat which most users can't reach. Toggle off for release.
    /// </summary>
    public bool showGPSDebugOverlay = true;

    /// <summary>
    /// Number of in-process test client bots to spawn alongside the host
    /// when testMode is on. Each gets its own GameClient + Network and
    /// joins the host's lobby automatically. Bots are switchable via
    /// number keys 1..N (host = 0). Default 3 keeps memory reasonable;
    /// bump for stress-testing voting / sabotage flows.
    /// </summary>
    public int testClientCount = 3;

    /// <summary>
    /// Per-bot network + display-name + sim-position state. The active slot
    /// (host = 0, bots = 1..N) gets WASD on the next tick.
    /// </summary>
    private class TestBot
    {
        public GameClient Client;
        public GameManager_Network Network;
        public string DisplayName;
        public GeoSus.Client.Position SimPosition;
        public bool Joined;
        public float LastSendTime;
    }
    private System.Collections.Generic.List<TestBot> _testBots = new System.Collections.Generic.List<TestBot>();
    /// <summary>Slot 0 = host (real player), 1..N = test bot index.</summary>
    private int _activeClientSlot = 0;

    void Awake()
    {
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this;
        DontDestroyOnLoad(gameObject);

        // Keep the screen on while the player is in the app. A geographic
        // social-deduction game asks the user to walk around for 5-15 minutes
        // staring at the map; default Android sleep timeout (15-60s) blacks
        // the screen out mid-round, drops GPS updates, and requires the
        // player to re-unlock the phone. Two layers of belt-and-suspenders:
        // (1) Unity's Screen.sleepTimeout, which works on most devices and
        // is one line, but is overridden by some MIUI/EMUI ROMs.
        // (2) Android FLAG_KEEP_SCREEN_ON on the activity window, harder for
        // OEM ROMs to override and the standard pattern for navigation/maps
        // apps. Wrapped in #if UNITY_ANDROID so editor/iOS skip it.
        Screen.sleepTimeout = SleepTimeout.NeverSleep;
        AcquireAndroidWakelock();
    }

    /// <summary>
    /// Set FLAG_KEEP_SCREEN_ON on the Unity activity's window. This is the
    /// standard navigation/maps-app pattern and survives ROM-level overrides
    /// of Unity's Screen.sleepTimeout. No-op on non-Android platforms.
    /// </summary>
    private static void AcquireAndroidWakelock()
    {
#if UNITY_ANDROID && !UNITY_EDITOR
        try
        {
            using (var player = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
            using (var activity = player.GetStatic<AndroidJavaObject>("currentActivity"))
            {
                // addFlags must run on the UI thread. Capture activity into a
                // local for the closure - AndroidJavaObject can be reused.
                var act = activity;
                act.Call("runOnUiThread", new AndroidJavaRunnable(() =>
                {
                    try
                    {
                        using (var window = act.Call<AndroidJavaObject>("getWindow"))
                        {
                            // WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
                            const int FLAG_KEEP_SCREEN_ON = 0x00000080;
                            window.Call("addFlags", FLAG_KEEP_SCREEN_ON);
                        }
                    }
                    catch (System.Exception ex)
                    {
                        Debug.LogWarning("[Wakelock] addFlags failed: " + ex.Message);
                    }
                }));
            }
        }
        catch (System.Exception ex)
        {
            Debug.LogWarning("[Wakelock] Android JNI bridge failed: " + ex.Message);
        }
#endif
    }

    void Start()
    {
        // The prefab default in SampleScene.unity is "Hrac" (Czech for
        // "player"). Treat it as equivalent to "no name set" so users who
        // never customize their name don't all show up identically. This
        // override only fires at startup; users who explicitly type "Hrac"
        // into the nickname field will still send "Hrac" via the live
        // DisplayName payload field.
        if (string.IsNullOrEmpty(displayName) || displayName == "Hrac")
            displayName = PlayerPrefs.GetString("PlayerName", GenerateUsername());

        gameClient = new GameClient(GenerateUUID(), displayName);
        networkSubsystem = new GameManager_Network(gameClient, this);
        mapSubsystem = new GameManager_Map(gameClient, null, buildingSettings, pathwaySettings, areaSettings);
        uiSubsystem = new GameManager_UI(gameClient);
        inputSubsystem = new GameManager_Input(gameClient, null, testMode);
        taskSubsystem = new GameManager_Tasks(gameClient, minigameScenes, this);

        if (testMode)
        {
            int n = Mathf.Max(0, testClientCount);
            for (int i = 0; i < n; i++)
            {
                var bot = new TestBot
                {
                    DisplayName = "TestBot" + (i + 1),
                };
                bot.Client = new GameClient(GenerateUUID(), bot.DisplayName);
                bot.Network = new GameManager_Network(bot.Client, null);
                bot.Network.OpenConnection();
                _testBots.Add(bot);
            }
        }

        networkSubsystem.OpenConnection();

        // Start GPS immediately at app launch. Acquiring a fix on a cold
        // device can take 5-30 seconds; if we wait until CreateLobby is
        // pressed, the lobby will be seeded with bad coords. Starting here
        // means the user's normal navigation through the menus gives the
        // GPS subsystem time to settle.
        inputSubsystem?.EnsureGPSStarted();

        // Load main menu after GameManager is ready
        if (!string.IsNullOrEmpty(firstMenuScene))
            SceneManager.LoadScene(firstMenuScene, LoadSceneMode.Single);
    }

    /// <summary>
    /// Draws a GPS status banner across the top of every screen. We use OnGUI
    /// rather than a uGUI Canvas element because OnGUI works without any
    /// scene wiring - we want this visible from the very first frame, on
    /// every screen, even if the lobby canvas hasn't been bound yet. This is
    /// a debug overlay; toggle showGPSDebugOverlay off for release builds.
    /// </summary>
    private void OnGUI()
    {
        if (!showGPSDebugOverlay) return;
        if (inputSubsystem == null) return;

        var diag = inputSubsystem.GpsDiagnostic;
        var label = "GPS: " + diag;

        // Scale font size to screen so it's legible on phones (HDPI) and
        // editor (lower DPI) alike. Phones tend to have ~400dpi; the
        // editor game view runs at ~100dpi.
        int fontSize = Mathf.Max(14, Screen.width / 50);

        var style = new GUIStyle(GUI.skin.label)
        {
            fontSize = fontSize,
            fontStyle = FontStyle.Bold,
            alignment = TextAnchor.MiddleLeft,
            wordWrap = false,
            normal = { textColor = Color.white }
        };

        // Width covers most of the screen so longer error strings don't get
        // clipped. Height auto-fits the chosen font size.
        float pad = fontSize * 0.5f;
        float bannerH = fontSize * 2f;
        var rect = new Rect(pad, pad, Screen.width - pad * 2, bannerH);

        // Translucent black background for legibility against the map.
        var prevColor = GUI.color;
        GUI.color = new Color(0f, 0f, 0f, 0.65f);
        GUI.Box(rect, GUIContent.none);
        GUI.color = prevColor;

        // Indent the label inside the box.
        var textRect = new Rect(rect.x + pad, rect.y, rect.width - pad * 2, rect.height);
        GUI.Label(textRect, label, style);

        // Second row: position-source picker (tap to cycle) + active client
        // indicator (testMode only). Both are diagnostic; the source picker
        // is the recovery path when one backend silently fails on a phone.
        float row2Y = rect.y + bannerH + pad * 0.5f;
        var btnStyle = new GUIStyle(GUI.skin.button)
        {
            fontSize = Mathf.Max(12, fontSize - 2),
            fontStyle = FontStyle.Bold,
            alignment = TextAnchor.MiddleCenter,
        };

        // Source button: shows current source name + invites tap.
        string sourceLabel = "Source: " + inputSubsystem.CurrentSourceName + "  [tap to cycle]";
        // Width sized to the text so the touch area matches the label.
        Vector2 sourceSize = btnStyle.CalcSize(new GUIContent(sourceLabel));
        float sourceW = Mathf.Min(Screen.width - pad * 2, sourceSize.x + pad * 2);
        var sourceRect = new Rect(pad, row2Y, sourceW, bannerH);
        if (GUI.Button(sourceRect, sourceLabel, btnStyle))
        {
            inputSubsystem.CycleNextPositionSource();
        }

        // Active-client indicator (only when we have test bots).
        if (testMode && _testBots.Count > 0)
        {
            string slot = _activeClientSlot == 0 ? "Host" : ("Bot " + _activeClientSlot);
            string indicator = $"WASD: {slot} (0..{_testBots.Count} to switch)";
            var indStyle = new GUIStyle(GUI.skin.label)
            {
                fontSize = Mathf.Max(12, fontSize - 2),
                fontStyle = FontStyle.Bold,
                alignment = TextAnchor.MiddleLeft,
                normal = { textColor = new Color(0.9f, 1f, 0.4f) },
            };
            Vector2 indSize = indStyle.CalcSize(new GUIContent(indicator));
            var indRect = new Rect(sourceRect.xMax + pad, row2Y, indSize.x + pad * 2, bannerH);
            GUI.color = new Color(0f, 0f, 0f, 0.65f);
            GUI.Box(indRect, GUIContent.none);
            GUI.color = prevColor;
            GUI.Label(new Rect(indRect.x + pad, indRect.y, indRect.width, indRect.height), indicator, indStyle);
        }
    }

    private void Update()
    {
        // Tick the SDK dispatcher so callbacks fire on main thread
        gameClient?.Update();
        if (testMode)
        {
            for (int i = 0; i < _testBots.Count; i++)
                _testBots[i].Client?.Update();
            HandleTestBotInput();
        }

        if (gameClient?.CurrentLobbyState != null)
        {
            uiSubsystem?.UpdateLobbyUI();
            taskSubsystem?.UpdateProximity();
        }
        if (gameClient?.MyRole == PlayerRole.Impostor)
            UpdateKillCooldown();

        inputSubsystem?.positionCheck();

        if (testMode) StepActiveTestBot();
    }

    /// <summary>
    /// Number-key handling for slot switching. 0 = host, 1..N = test bot N.
    /// Suppress host WASD when a non-host bot is active so the host capsule
    /// doesn't drift while the user is moving a bot. Only fires when
    /// testMode is on; release builds never see this path.
    /// </summary>
    private void HandleTestBotInput()
    {
        // 0 = host. 1..9 = bots (capped by Unity KeyCode.Alpha9).
        if (Input.GetKeyDown(KeyCode.Alpha0)) _activeClientSlot = 0;
        for (int i = 1; i <= 9 && i <= _testBots.Count; i++)
        {
            if (Input.GetKeyDown(KeyCode.Alpha0 + i)) _activeClientSlot = i;
        }

        // Tell the host's input subsystem to ignore WASD when a bot is active.
        if (inputSubsystem != null)
            inputSubsystem.SuppressWasd = (_activeClientSlot != 0);
    }

    /// <summary>
    /// If the active slot is a bot, step its sim position from WASD axes
    /// and send to the server. Idle bots get a periodic keep-alive so their
    /// avatars don't time out.
    /// </summary>
    private void StepActiveTestBot()
    {
        if (_testBots.Count == 0) return;
        var state = gameClient?.CurrentLobbyState;
        if (state == null || state.MapData == null) return;

        // Lazy-init each bot's sim position to the lobby's map center on
        // first lobby state. Until the bot has joined a lobby it can't
        // send position updates.
        for (int i = 0; i < _testBots.Count; i++)
        {
            var bot = _testBots[i];
            if (!bot.Joined) continue;
            if (bot.SimPosition.Lat == 0 && bot.SimPosition.Lon == 0)
            {
                // Spawn each bot in a small ring around the map center so
                // they don't all stack on top of each other on frame one.
                double offsetLat = 0.00003 * Mathf.Cos(i * Mathf.PI * 2f / Mathf.Max(1, _testBots.Count));
                double offsetLon = 0.00003 * Mathf.Sin(i * Mathf.PI * 2f / Mathf.Max(1, _testBots.Count));
                bot.SimPosition = new GeoSus.Client.Position(
                    state.MapData.Center.Lat + offsetLat,
                    state.MapData.Center.Lon + offsetLon);
                bot.Client.UpdatePosition(bot.SimPosition);
                bot.LastSendTime = Time.time;
            }
        }

        // WASD only drives the active bot.
        if (_activeClientSlot >= 1 && _activeClientSlot <= _testBots.Count)
        {
            var bot = _testBots[_activeClientSlot - 1];
            if (bot.Joined)
            {
                float dx = Input.GetAxis("Horizontal");
                float dy = Input.GetAxis("Vertical");
                const double speed = 0.00001;
                bool moved = Mathf.Abs(dx) > 0.001f || Mathf.Abs(dy) > 0.001f;
                if (moved)
                {
                    bot.SimPosition = new GeoSus.Client.Position(
                        bot.SimPosition.Lat + dy * speed,
                        bot.SimPosition.Lon + dx * speed);
                }
                // Send on movement OR on keep-alive cadence so the server
                // doesn't drop our presence.
                bool dueKeepAlive = (Time.time - bot.LastSendTime) >= 1.0f;
                if (moved || dueKeepAlive)
                {
                    bot.Client.UpdatePosition(bot.SimPosition);
                    bot.LastSendTime = Time.time;
                }
            }
        }
        else
        {
            // No bot is active. All bots get keep-alive only.
            for (int i = 0; i < _testBots.Count; i++)
            {
                var bot = _testBots[i];
                if (!bot.Joined) continue;
                if ((Time.time - bot.LastSendTime) >= 1.0f)
                {
                    bot.Client.UpdatePosition(bot.SimPosition);
                    bot.LastSendTime = Time.time;
                }
            }
        }
    }


    void OnEnable()
    {
        SceneManager.sceneLoaded += OnSceneLoaded;
    }
    void OnDisable()
    {
        SceneManager.sceneLoaded -= OnSceneLoaded;
    }

    /// <summary>
    /// After Client.unity loads, re-bind all canvas/HUD references because
    /// those GameObjects don't exist in the Art menu scenes.
    /// </summary>
    private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        if (scene.name == "Client")
        {
            var roots = scene.GetRootGameObjects();

            // Find a root or deep GameObject by name in the loaded scene
            GameObject FindGO(string n) {
                foreach (var go in roots) {
                    if (go.name == n) return go;
                    var found = go.transform.Find(n);
                    if (found != null) return found.gameObject;
                }
                return null;
            }
            Canvas FindCanvas(string n) {
                var go = FindGO(n);
                return go != null ? go.GetComponent<Canvas>() : null;
            }

            // ── Build HUD BEFORE BindClientScene so FindTMP/Find can locate new elements ──
            var inGameGO = FindGO("InGame");
            if (inGameGO != null)
            {
                var builder = inGameGO.GetComponent<InGameHUDBuilder>()
                           ?? inGameGO.AddComponent<InGameHUDBuilder>();
                builder.BuildNow();
            }

            // ── Wire canvases (after HUD is built) ──
            // Apply our standard CanvasScaler (1080x1920 reference, match=0.5)
            // to every canvas in the scene before binding so layouts scale
            // identically across phones and tablets without per-device tweaks.
            var cJoin = FindCanvas(CanvasNameJoinCreate);
            var cLobby = FindCanvas(CanvasNameInLobby);
            var cLoad = FindCanvas(CanvasNameLoading);
            var cGame = FindCanvas(CanvasNameGame);
            InGameHUDBuilder.ConfigureCanvasScaler(cJoin);
            InGameHUDBuilder.ConfigureCanvasScaler(cLobby);
            InGameHUDBuilder.ConfigureCanvasScaler(cLoad);
            InGameHUDBuilder.ConfigureCanvasScaler(cGame);
            uiSubsystem?.BindClientScene(cJoin, cLobby, cLoad, cGame);

            // ── Wire map center point and player capsule ──
            var mapCenter = FindGO("MapCenterPoint");
            var player    = FindGO("Capsule");
            mapSubsystem?.SetMapCenterPoint(mapCenter);
            inputSubsystem?.SetPlayerObject(player);

            // ── Attach camera controller to Main Camera ──
            var mainCamGO = FindGO("Main Camera");
            if (mainCamGO != null)
            {
                var camCtrl = mainCamGO.GetComponent<MapCameraController>()
                           ?? mainCamGO.AddComponent<MapCameraController>();
                camCtrl.SetTarget(player);
            }

            // If MapDataReady arrived before Client scene finished loading,
            // this will build the map now that scene references are valid.
            networkSubsystem?.OnClientSceneReady();
        }
        else if (scene.name == "create" || scene.name == "join loading")
        {
            // Lobby scene just loaded — ensure LobbyDisplayUI refreshes once
            // its Start() has run and registered itself (happens before Update).
            uiSubsystem?.NotifyLobbyChanged();
        }
    }

    private float _killCooldownSeconds = 0f;
    private const float KillCooldownDuration = 20f;

    private void UpdateKillCooldown()
    {
        if (_killCooldownSeconds > 0)
        {
            _killCooldownSeconds -= Time.deltaTime;
            // Mirror into GameState so UI reads from the single source of truth
            if (networkSubsystem?.State != null)
                networkSubsystem.State.KillCooldownRemaining = _killCooldownSeconds;
            uiSubsystem?.SetKillCooldownText($"Kill: {Mathf.CeilToInt(_killCooldownSeconds)}s");
        }
        else
        {
            _killCooldownSeconds = 0f;
            if (networkSubsystem?.State != null)
                networkSubsystem.State.KillCooldownRemaining = 0;
            uiSubsystem?.SetKillCooldownText("");
        }
    }

    /// <summary>
    /// Called by the ActionButton. Routes to kill / report / emergency / use-task
    /// depending on current proximity state.
    /// </summary>
    public void PerformAction()
    {
        if (uiSubsystem == null || uiSubsystem.IsPlayerDead) return;

        bool isImpostor = gameClient?.MyRole == PlayerRole.Impostor;

        // P13b: pull per-lobby distances from the server-snapshotted settings
        // instead of hardcoding 5m for every check. ?? fallback keeps the
        // pre-P13b behavior on old server builds that don't ship settings.
        var settings = networkSubsystem?.State?.Settings;
        double reportDist = settings?.ReportDistanceM ?? 5.0;
        double emergencyDist = settings?.EmergencyMeetingCallRadiusM ?? 5.0;
        double killDist = settings?.KillDistanceM ?? 5.0;

        // 1. Nearby task → USE
        var nearbyTask = taskSubsystem?.NearbyTask;
        if (nearbyTask != null && !isImpostor)
        {
            taskSubsystem.TriggerNearbyTask();
            return;
        }

        // 2. Nearby body → REPORT
        if (!uiSubsystem.IsCommsBlackout)
        {
            var nearbyBody = gameClient?.FindNearbyBody(reportDist);
            if (nearbyBody != null)
            {
                gameClient.ReportBody(nearbyBody.BodyId);
                return;
            }

            // 3. Near map centre → EMERGENCY
            if (gameClient?.CurrentLobbyState?.MapData != null)
            {
                double distToCenter = gameClient.MyPosition.DistanceTo(gameClient.CurrentLobbyState.MapData.Center);
                if (distToCenter <= emergencyDist)
                {
                    gameClient.CallEmergencyMeeting();
                    return;
                }
            }
        }

        // 4. Impostor kill
        if (isImpostor && _killCooldownSeconds <= 0)
        {
            var targetUuid = gameClient?.FindNearbyPlayer(killDist);
            if (!string.IsNullOrEmpty(targetUuid))
            {
                gameClient.Kill(targetUuid);
                _killCooldownSeconds = KillCooldownDuration;
            }
        }
    }

    /// <summary>Called by Impostor sabotage buttons.</summary>
    public void StartSabotage(int typeIndex)
    {
        gameClient?.Send(new GeoSus.Client.StartSabotage { SabotageType = (SabotageType)typeIndex });
    }

    /// <summary>Called by the meeting vote buttons. Pass null to skip.</summary>
    public void CastVote(string targetUuid)
    {
        gameClient?.Vote(targetUuid);
    }

    protected string GenerateUUID()
    {
        return System.Guid.NewGuid().ToString();
    }
    protected string GenerateUsername()
    {
        return "Player" + UnityEngine.Random.Range(1000, 9999).ToString();
    }

    /// <summary>
    /// Pull the nickname input field's current text into displayName +
    /// gameClient.DisplayName + PlayerPrefs before sending a network
    /// action. Defensive against any TMP_InputField / soft-keyboard race
    /// where the user types and immediately taps a button: onValueChanged
    /// normally fires before the click handler in the same frame, but
    /// some Android keyboards batch text events oddly. Call this at the
    /// top of any Create/Join/Rename flow. No-op if the input field
    /// doesn't exist in the current scene.
    /// </summary>
    private void CommitNicknameFromInput()
    {
        var nameGO = GameObject.Find("name");
        if (nameGO == null) return;
        var field = nameGO.GetComponent<TMPro.TMP_InputField>();
        if (field == null) return;
        // Force the InputField to flush any pending soft-keyboard text.
        // ForceLabelUpdate() is harmless if there's nothing pending.
        field.ForceLabelUpdate();
        string typed = (field.text ?? "").Trim();
        if (string.IsNullOrEmpty(typed)) return;
        if (typed == displayName) return; // already in sync, skip the writes
        displayName = typed;
        if (gameClient != null) gameClient.DisplayName = typed;
        PlayerPrefs.SetString("PlayerName", typed);
        PlayerPrefs.Save();
    }

    // Called by HostLobbyUI
    public void CreateLobbyButton()
    {
        CommitNicknameFromInput();
        // Refuse to create a lobby without a real GPS fix. The previous
        // behavior of silently using a hardcoded Czechia fallback meant the
        // game always started at the same place no matter where the host was,
        // and the player capsule would spawn miles away in coordinate space
        // because they're at their real GPS while the map was built around
        // the fallback. Both bugs share this single gate.
        if (inputSubsystem?.LastKnownPosition == null)
        {
            // testMode bypasses the GPS gate entirely so debug runs still work.
            if (!testMode)
            {
                // Surface the actual GPS state in both logs and the toast
                // instead of the generic "Waiting for GPS fix..." that hides
                // permission/timeout/device-disabled distinctions.
                string diag = inputSubsystem?.GpsDiagnostic ?? "no input subsystem";
                Debug.LogWarning("[GameManager] CreateLobby blocked. " + diag);
                uiSubsystem?.ShowToast("Cannot create lobby. " + diag);
                inputSubsystem?.EnsureGPSStarted();
                return;
            }
        }

        var pos = inputSubsystem?.LastKnownPosition;
        double lat = pos?.Lat ?? 0;
        double lon = pos?.Lon ?? 0;
        networkSubsystem.CreateLobby(lat, lon, pendingRadius, pendingImpostorCount, pendingTaskCount, pendingSettings);
        if (testMode) StartCoroutine(ConnectTestClients());
    }

    // Called by JoinLobbyUI with the code from the input field
    public void JoinLobbyButton(string code)
    {
        CommitNicknameFromInput();
        if (!string.IsNullOrEmpty(code))
            networkSubsystem.JoinLobby(code);
        else
            Debug.LogWarning("Join code is empty!");
    }

    public void LeaveLobbyButton()
    {
        networkSubsystem.LeaveLobby();
    }

    public void StartGameButton()
    {
        networkSubsystem.StartGame();
    }

    void OnApplicationQuit()
    {
        gameClient?.Disconnect();
        for (int i = 0; i < _testBots.Count; i++)
            _testBots[i].Client?.Disconnect();
    }

    IEnumerator ConnectTestClients()
    {
        if (_testBots.Count == 0) yield break;

        // Wait until host lobby code exists
        float wait = 0f;
        while ((gameClient?.CurrentLobbyState == null || string.IsNullOrEmpty(gameClient.CurrentLobbyState.JoinCode)) && wait < 20f)
        {
            wait += 0.25f;
            yield return new WaitForSeconds(0.25f);
        }

        var joinCode = gameClient?.CurrentLobbyState?.JoinCode;
        if (string.IsNullOrEmpty(joinCode))
        {
            Debug.LogWarning("[TestMode] Could not join test bots: join code not available.");
            yield break;
        }

        // Wait until every bot's client has finished its TCP handshake.
        // IsReady flips once ClientHello + ClientHelloAck round-trip.
        wait = 0f;
        bool allReady;
        do
        {
            allReady = true;
            for (int i = 0; i < _testBots.Count; i++)
            {
                if (_testBots[i].Client == null || !_testBots[i].Client.IsReady)
                {
                    allReady = false;
                    break;
                }
            }
            if (!allReady)
            {
                wait += 0.25f;
                yield return new WaitForSeconds(0.25f);
            }
        } while (!allReady && wait < 20f);

        if (!allReady)
        {
            Debug.LogWarning("[TestMode] Some test bots not ready, joining the ready ones only.");
        }

        for (int i = 0; i < _testBots.Count; i++)
        {
            var bot = _testBots[i];
            if (bot.Client != null && bot.Client.IsReady)
            {
                bot.Network?.JoinLobby(joinCode);
                bot.Joined = true;
            }
        }
        Debug.Log($"[TestMode] {_testBots.Count} bot(s) joined lobby with code {joinCode}.");
    }
}
