// Assets/_Project/Scripts/UI/HUDController.cs
using System.Collections;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
using UnityEngine.UIElements;
using TD.Core;
using TD.Gameplay;
using TD.Gameplay.Draft;
using TD.Towers;
using TD.UI.Minimap;

namespace TD.UI
{
    /// <summary>
    /// Drives the in-match HUD. Requires a UIDocument on the same GameObject.
    /// Wires gold display, tower command grid, tooltip, rejection messages,
    /// and minimap RenderTexture to their UI Toolkit counterparts.
    /// </summary>
    [RequireComponent(typeof(UIDocument))]
    public class HUDController : MonoBehaviour
    {
        public static HUDController Instance { get; private set; }

        // ----- Inspector --------------------------------------------------

        [Header("Scene References")] [Tooltip("The local client's TowerPlacementController.")] [SerializeField]
        private TowerPlacementController placementController;

        [Tooltip("The local client's TowerPaintController (drives the Paint tab + paint cursor).")]
        [SerializeField] private TowerPaintController paintController;

        [Tooltip("The TowerPlacementManager NetworkObject in the scene.")] [SerializeField]
        private TowerPlacementManager placementManager;

        [Tooltip("The local client's CameraController. Used by the minimap for click-to-jump " +
                 "and drag-to-pan.")]
        [SerializeField]
        private CameraController cameraController;

        [Header("Settings")] [SerializeField] private float rejectionMessageDuration = 2.5f;

        [Tooltip("Maximum visible height of the chat feed in pixels. Content past this " +
                 "height is clipped — older messages scroll off the top of the visible area " +
                 "but stay in history (scroll up while chat is open to view).")]
        [SerializeField]
        private float chatMaxHeight = 280f;

        [Tooltip("Maximum messages kept in chat history. Defaults to effectively unlimited " +
                 "(int.MaxValue) — every message sent during a match stays scrollable. " +
                 "Lower the value if a long match ever shows DOM perf issues; this field " +
                 "is the safety valve, not a normal-play limit.")]
        [SerializeField]
        private int chatMaxMessages = int.MaxValue;

        [Tooltip("Color used for SYSTEM chat messages (e.g. 'Life Lost', income changes).")] [SerializeField]
        private Color chatSystemColor = new Color(1f, 0.7f, 0.2f);

        [Tooltip("Color used for PLAYER chat message bodies. Sender prefix uses the player's slot color.")]
        [SerializeField]
        private Color chatPlayerColor = new Color(0.92f, 0.92f, 0.92f);

        // ----- Cached UI element references -------------------------------

        private Label goldLabel;
        private Label waveLabel;
        private Label livesLabel;
        private Label nextWaveLabel; // prep countdown ("next: 0:12")
        private Label leakedLabel; // local player's origin-leak count ("leaked: 3")
        private Label incomeLabel; // top-bar per-wave gold-earned counter ("+150 g/wave")
        private VisualElement playerListContainer; // right-panel scoreboard rows
        private Label portraitName;
        private Label levelLabel;
        private VisualElement statLines;
        private VisualElement commandGrid;

        private VisualElement actionFrame;   // hidden via display:none when no actions are available
        private VisualElement commandTabs;   // Build/Paint tab row — shown only for a Builder selection
        private Button tabBuild;
        private Button tabPaint;
        private VisualElement buildProgressContainer;   // info-panel sub-view, shown for BuildSiteVisual selections
        private VisualElement buildProgressFill;        // width driven each frame from progress
        private Label         buildProgressPercent;

        private Label ttTitle;
        private Label ttDesc;
        private Label ttStats;
        private Label ttCost;
        private Label rejectionLabel;
        private VisualElement portraitFrame;

        // Enemy-info sub-panel — built programmatically and inserted into stat-lines
        // whenever an Enemy is selected. Cached so we can update HP each frame
        // without rebuilding the elements.
        private VisualElement enemyHealthBar;
        private VisualElement enemyHealthFill;
        private Label enemyHealthText;

        // Match-end overlay — built once on Start and toggled on Phase changes.
        private VisualElement matchEndOverlay;

        // Buff menu overlay — toggled by the B key via ToggleBuffMenu().
        private VisualElement buffMenuOverlay;
        private VisualElement buffMenuContent;
        private Label matchEndTitle;

        // Draft overlay (roguelike between-waves draft). Non-modal: the card row floats at
        // top-center so the player can keep building during prep. The "Buy Roll" button is
        // shown when no draft is pending so a paid roll can be bought any time.
        private VisualElement draftPanel;
        private VisualElement draftCardRow;
        private Button        draftBuyButton;
        private bool          draftSubscribed;
        private PlayerDraft   subscribedDraft;

        // Chat panel (bottom-left, above portrait) — programmatic. The container
        // holds both the scrollable feed and the input. Highlight + scroll
        // interactivity are toggled on the container when typing.
        private VisualElement chatContainer;
        private ScrollView chatFeed;
        private TextField chatInput;
        private bool chatInputOpen;

        // Frame on which the chat input was opened or closed. Enter on that frame
        // and the next one is ignored to prevent the open/close-triggering keypress
        // from also being consumed by the input or the open-toggle. Without this,
        // pressing Enter to open chat would immediately submit an empty message.
        private int chatToggleSuppressFrame = -1;

        // Set true whenever the chat input or any other text field on the HUD has
        // keyboard focus. Camera, builder input, and hotkey handlers all gate on
        // this to keep typing from driving gameplay.
        public static bool IsTextInputActive { get; private set; }

        // ----- State ------------------------------------------------------

        // Which command-grid tab is active for a Builder selection. Build = tower buttons
        // (default), Paint = color swatches. Reset to Build whenever a non-builder is
        // selected so reselecting a builder always starts on Build.
        private enum CommandTab { Build, Paint }
        private CommandTab activeTab = CommandTab.Build;

        private Coroutine rejectionFadeCoroutine;
        private bool placementManagerReady; // true once TowerPlacementManager.Instance is non-null
        private bool uiInitialized;
        private bool selectionSubscribed; // true once we've successfully hooked SelectionState.OnSelectionChanged
        private bool matchStateSubscribed; // true once OnPhaseChanged is hooked
        private bool deckSubscribed; // true once we've hooked the local PlayerTowerDeck.OnDeckChanged
        private PlayerTowerDeck subscribedDeck; // the deck we hooked, so we can unsubscribe the same instance
        private MinimapView minimapView;
        private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us

        // ----- Hotkeys ----------------------------------------------------
        //
        // Per-slot hotkey layout matching the WC3 / Wintermaul Reforged convention.
        // Slot index 0..14 corresponds to row-major position in the 5×3 action grid.
        // To rebind, edit this array. (Per-tower hotkeys could live on TowerDefinition
        // later; for now the position-based layout is enough and predictable.)
        private static readonly Key[] HotkeyLayout =
        {
            Key.Q, Key.W, Key.E, Key.R, Key.T,
            Key.A, Key.S, Key.D, Key.F, Key.G,
            Key.Z, Key.X, Key.C, Key.V, Key.B,
        };

        // Active hotkey bindings — rebuilt on every selection change inside
        // PopulateGridForSelection. HandleHotkeys reads this every Update.
        private readonly List<HotkeyBinding> hotkeyBindings = new List<HotkeyBinding>();

        private readonly struct HotkeyBinding
        {
            public readonly Key Key;
            public readonly VisualElement Button; // for enabledSelf gating
            public readonly System.Action Action;

            public HotkeyBinding(Key k, VisualElement b, System.Action a)
            {
                Key = k;
                Button = b;
                Action = a;
            }
        }

        // ----- Static hit-test probe --------------------------------------

        // Set when InitializeUI succeeds; cleared on OnDestroy. Non-UI systems (camera,
        // input handlers) can query IsPointerOverInteractiveHud without taking a direct
        // reference to HUDController.
        private static IPanel s_hudPanel;

        /// <summary>
        /// True if <paramref name="screenMousePos"/> falls over an interactive (non-ignore)
        /// HUD element. Non-UI systems that consume mouse input (camera scroll-zoom, edge-pan)
        /// should gate their handling on this so a cursor over the minimap, command grid, or
        /// any other interactive HUD region doesn't drive both the HUD and the world at once.
        /// </summary>
        /// <remarks>
        /// Convention: <paramref name="screenMousePos"/> uses Unity Input System screen coords
        /// (origin bottom-left, y up). Returns false before the HUD has initialized; safe to
        /// call from any system at any time.
        /// </remarks>
        public static bool IsPointerOverInteractiveHud(Vector2 screenMousePos)
        {
            if (s_hudPanel == null) return false;

            // Coord convention rabbit hole:
            //   - Screen mouse position: origin bottom-left, y up (Unity Input System).
            //   - UI Toolkit panel coords: origin top-left, y down.
            //
            // RuntimePanelUtils.ScreenToPanel converts the SCALE (e.g., reference resolution
            // vs. actual resolution) but does NOT flip Y. We flip manually using the visual
            // tree's height so the result works regardless of PanelSettings scale mode.
            //
            // Subtle: visualTree.worldBound height may be 0 for one frame on the very first
            // layout pass. The caller (CameraController) checks the result against "is over
            // interactive HUD"; a one-frame false positive (camera zooms when it shouldn't)
            // is harmless and self-corrects the next frame.
            Vector2 scaled = RuntimePanelUtils.ScreenToPanel(s_hudPanel, screenMousePos);
            float panelHeight = s_hudPanel.visualTree.worldBound.height;
            Vector2 panelPos = new Vector2(scaled.x, panelHeight - scaled.y);

            // panel.Pick returns null when the topmost element under the point has
            // PickingMode.Ignore (or there's no element there at all). Non-null means an
            // interactive HUD element is under the cursor.
            return s_hudPanel.Pick(panelPos) != null;
        }

        // ----- Lifecycle --------------------------------------------------

        private void Start()
        {
            // UIDocument creates its panel in OnEnable, which runs after all
            // Awake() calls. Accessing rootVisualElement in Awake() is too early.
            // Start() is safe because all OnEnable() calls have completed by then.
            InitializeUI();
            TryReadyPlacementManager();

            // Subscription fallback: if OnEnable couldn't subscribe (SelectionState.Awake
            // hadn't run yet), Start() is guaranteed to be after all Awake calls in the
            // scene. Retry here. Without this fallback, the HUD silently misses every
            // selection event for the rest of the session.
            TrySubscribeSelection();

            // Seed portrait/grid in case the builder already auto-selected before Start.
            HandleSelectionChanged(SelectionState.Instance?.SelectedObject);

            TrySubscribeMatchState();
        }

        private void TrySubscribeMatchState()
        {
            if (matchStateSubscribed) return;
            if (MatchState.Instance == null) return;
            MatchState.Instance.OnPhaseChanged += HandlePhaseChanged;
            matchStateSubscribed = true;
        }

        // Hook the local player's deck so the command grid rebuilds live when a draft
        // grant lands while the builder is already selected. The local deck may not
        // exist for the first few frames (spawn race) — retried each Update until it does.
        private void TrySubscribeDeck()
        {
            var deck = PlayerTowerDeck.Local;
            if (deck == null) return;
            deck.OnDeckChanged += HandleDeckChanged;
            subscribedDeck = deck;
            deckSubscribed = true;
            // The deck may have populated before we subscribed — rebuild now so the
            // grid reflects the current contents.
            PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
        }

        private void HandleDeckChanged()
        {
            PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
        }

        // Hook the local player's draft so the overlay rebuilds when options are offered,
        // picked, or rerolled. Retried each Update until the local PlayerDraft exists.
        private void TrySubscribeDraft()
        {
            var draft = PlayerDraft.Local;
            if (draft == null) return;
            draft.OnDraftChanged += HandleDraftChanged;
            subscribedDraft = draft;
            draftSubscribed = true;
            RebuildDraftCards();
        }

        private void HandleDraftChanged()
        {
            RebuildDraftCards();
        }

        // ----- Draft overlay ----------------------------------------------

        // Non-modal draft UI: a card row floats at top-center while a draft is active, and
        // a "Buy Roll" button shows when no draft is pending. The host panel ignores picks
        // so empty space clicks through to the world — the player keeps building during prep.
        private void BuildDraftOverlay(VisualElement root)
        {
            draftPanel = new VisualElement();
            draftPanel.style.position   = Position.Absolute;
            draftPanel.style.left       = 0;
            draftPanel.style.right      = 0;
            draftPanel.style.top        = 70; // below the top bar
            draftPanel.style.alignItems = Align.Center;
            draftPanel.pickingMode      = PickingMode.Ignore;
            draftPanel.style.display    = DisplayStyle.None;

            var box = new VisualElement();
            box.style.flexDirection = FlexDirection.Column;
            box.style.alignItems    = Align.Center;
            box.pickingMode         = PickingMode.Ignore;

            draftCardRow = new VisualElement();
            draftCardRow.style.flexDirection = FlexDirection.Row;
            draftCardRow.pickingMode         = PickingMode.Ignore;
            box.Add(draftCardRow);

            draftBuyButton = new Button(() => PlayerDraft.Local?.RequestBuyRerollRpc())
            {
                text = "Buy Roll"
            };
            draftBuyButton.style.marginTop = 6;
            box.Add(draftBuyButton);

            draftPanel.Add(box);
            root.Add(draftPanel);

            UpdateDraftVisibility();
        }

        // Rebuilds the option cards from the local player's current offer. Called on
        // OnDraftChanged (offered / picked / rerolled).
        private void RebuildDraftCards()
        {
            if (draftCardRow == null) return;
            draftCardRow.Clear();

            var draft = PlayerDraft.Local;
            var pool  = DraftPool.Instance;
            if (draft != null && pool != null)
            {
                for (int i = 0; i < draft.OptionCount; i++)
                {
                    int id = draft.GetOptionId(i);
                    var option = pool.Get(id);
                    if (option == null) continue;
                    draftCardRow.Add(CreateDraftCard(option, id, draft));
                }
            }

            UpdateDraftVisibility();
        }

        private VisualElement CreateDraftCard(DraftOption option, int optionId, PlayerDraft draft)
        {
            var card = new VisualElement();
            card.style.width         = 150;
            card.style.marginLeft    = card.style.marginRight = 4;
            card.style.paddingTop    = card.style.paddingBottom = 10;
            card.style.paddingLeft   = card.style.paddingRight = 10;
            card.style.backgroundColor = new Color(0.10f, 0.10f, 0.13f, 0.96f);
            card.style.borderTopWidth = card.style.borderBottomWidth =
                card.style.borderLeftWidth = card.style.borderRightWidth = 2;
            var border = new Color(0.4f, 0.4f, 0.45f);
            card.style.borderTopColor = card.style.borderBottomColor =
                card.style.borderLeftColor = card.style.borderRightColor = border;
            card.style.alignItems = Align.Center;

            var title = new Label(option.DisplayName);
            title.style.fontSize = 14;
            title.style.color    = Color.white;
            title.style.unityFontStyleAndWeight = FontStyle.Bold;
            title.style.whiteSpace   = WhiteSpace.Normal;
            title.style.unityTextAlign = TextAnchor.MiddleCenter;
            title.style.marginBottom = 6;
            card.Add(title);

            if (option.Icon != null)
            {
                var img = new Image { sprite = option.Icon };
                img.style.width  = 48;
                img.style.height = 48;
                img.style.marginBottom = 6;
                card.Add(img);
            }

            var desc = new Label(option.Description ?? "");
            desc.style.fontSize = 11;
            desc.style.color    = new Color(0.82f, 0.82f, 0.82f);
            desc.style.whiteSpace = WhiteSpace.Normal;
            desc.style.unityTextAlign = TextAnchor.MiddleCenter;
            desc.style.marginBottom = 8;
            card.Add(desc);

            var pick = new Button(() => draft.RequestPickRpc(optionId)) { text = "Pick" };
            card.Add(pick);

            return card;
        }

        // Per-frame: toggles the card row vs. the buy button and keeps the buy button's
        // label/enabled state in sync with gold. Cheap (mirrors the gold label's per-frame
        // refresh); only allocates a string while the buy button is shown.
        private void UpdateDraftVisibility()
        {
            if (draftPanel == null) return;

            var draft   = PlayerDraft.Local;
            var service = DraftService.Instance;
            var gold    = PlayerGoldManager.Local;

            bool hasDraft   = draft != null && draft.HasActiveDraft;
            bool canShowBuy = draft != null && service != null && !hasDraft;

            if (draftCardRow != null)
                draftCardRow.style.display = hasDraft ? DisplayStyle.Flex : DisplayStyle.None;

            if (draftBuyButton != null)
            {
                draftBuyButton.style.display = canShowBuy ? DisplayStyle.Flex : DisplayStyle.None;
                if (canShowBuy)
                {
                    int cost = service.RerollCost;
                    draftBuyButton.text = $"Buy Roll ({cost}g)";
                    draftBuyButton.SetEnabled(gold != null && gold.CurrentGold >= cost);
                }
            }

            draftPanel.style.display = (hasDraft || canShowBuy) ? DisplayStyle.Flex : DisplayStyle.None;
        }

        private void InitializeUI()
        {
            var doc = GetComponent<UIDocument>();
            if (doc == null)
            {
                Debug.LogError("[HUDController] No UIDocument component found.");
                return;
            }

            var root = doc.rootVisualElement;
            if (root == null)
            {
                Debug.LogError("[HUDController] rootVisualElement is null. " +
                               "Check that Panel Settings and Source Asset are assigned.");
                return;
            }

            // Cache element references — log a warning for any that are missing
            // so UXML/USS mismatches surface immediately.

            goldLabel              = Require<Label>(root, "gold-label");
            waveLabel              = Require<Label>(root, "wave-label");
            livesLabel             = Require<Label>(root, "lives-label");
            nextWaveLabel          = Require<Label>(root, "next-wave-label");
            leakedLabel            = Require<Label>(root, "leaked-label");
            incomeLabel            = Require<Label>(root, "income-label");
            playerListContainer    = Require<VisualElement>(root, "player-list");
            portraitName           = Require<Label>(root, "portrait-name");
            levelLabel             = Require<Label>(root, "level-label");
            statLines              = Require<VisualElement>(root, "stat-lines");
            commandGrid            = Require<VisualElement>(root, "command-grid");
            actionFrame            = Require<VisualElement>(root, "action-frame");
            commandTabs            = Require<VisualElement>(root, "command-tabs");
            tabBuild               = Require<Button>(root, "tab-build");
            tabPaint               = Require<Button>(root, "tab-paint");

            if (tabBuild != null)  tabBuild.clicked  += () => SwitchTab(CommandTab.Build);
            if (tabPaint != null)  tabPaint.clicked  += () => SwitchTab(CommandTab.Paint);

            buildProgressContainer = Require<VisualElement>(root, "build-progress");
            buildProgressFill = Require<VisualElement>(root, "build-progress-fill");
            buildProgressPercent = Require<Label>(root, "build-progress-percent");
            ttTitle = Require<Label>(root, "tt-title");
            ttDesc = Require<Label>(root, "tt-desc");
            ttStats = Require<Label>(root, "tt-stats");
            ttCost = Require<Label>(root, "tt-cost");
            rejectionLabel = Require<Label>(root, "rejection-label");

            // Map area and its transparent ancestors must not consume pointer
            // events so clicks reach the 3D scene underneath. The bottom-ui is now
            // a transparent strip too — the *individual frames* are the opaque
            // interactive surfaces, so the empty margins on either side click
            // through to the world.
            SetPickIgnore(root, "hud-root");
            SetPickIgnore(root, "main-area");
            SetPickIgnore(root, "map-area");
            SetPickIgnore(root, "bottom-ui");
            if (rejectionLabel != null)
                rejectionLabel.pickingMode = PickingMode.Ignore;

            // Minimap. The MinimapView owns the two sub-elements (terrain + entity overlay)
            // and drives them; we just hand it the host container and the camera controller.
            // Bake is deferred until LevelLoader is ready — view tries each frame in Tick().
            var minimapContainer = root.Q<VisualElement>("minimap");
            if (minimapContainer != null)
            {
                if (cameraController == null)
                    Debug.LogWarning("[HUDController] cameraController not assigned. " +
                                     "Click-to-jump and drag-to-pan on the minimap will be disabled.");
                minimapView = new MinimapView(minimapContainer, cameraController);
            }

            // Portrait click → center camera on current selection (WC3 convention).
            portraitFrame = root.Q<VisualElement>("portrait-frame");
            if (portraitFrame != null)
                portraitFrame.RegisterCallback<ClickEvent>(OnPortraitClicked);

            // Build the match-end overlay (Victory / Defeat + Retry). Hidden until
            // MatchState.OnPhaseChanged fires Victory or Defeat.
            BuildMatchEndOverlay(root);

            // Build the buff menu overlay. Hidden until the player presses B.
            BuildBuffMenuOverlay(root);

            // Build the draft overlay (roguelike between-waves draft). Hidden until the
            // local player has an active draft (or to show the paid "Buy Roll" button).
            BuildDraftOverlay(root);

            // Chat feed + input. Anchored bottom-left, just above the portrait/bottom-ui bar.
            // Player typing toggled with Enter; system messages (e.g. life lost) post via
            // ChatService.PostLocalSystem on every peer.
            BuildChatPanel(root);

            // Publish the panel so non-UI systems can query "is pointer over the HUD".
            // Stored on `myPanel` too so OnDestroy only clears the static if it still
            // points at this instance (defensive against re-creation overlap).
            myPanel = root.panel;
            s_hudPanel = myPanel;

            uiInitialized = true;
        }

        private void Awake()
        {
            Instance = this;
        }

        private void OnEnable()
        {
            TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage;
            WaveManager.OnLifeLost += HandleLifeLost;
            ChatService.OnMessageReceived += HandleChatMessage;
            // Try to subscribe now; if SelectionState.Awake hasn't run yet (Unity does
            // not guarantee Awake/OnEnable ordering across objects), Start will retry.
            TrySubscribeSelection();
        }

        private void OnDisable()
        {
            TowerPlacementController.OnRejectionMessageReady -= ShowRejectionMessage;
            WaveManager.OnLifeLost -= HandleLifeLost;
            ChatService.OnMessageReceived -= HandleChatMessage;
            if (selectionSubscribed && SelectionState.Instance != null)
            {
                SelectionState.Instance.OnSelectionChanged -= HandleSelectionChanged;
                selectionSubscribed = false;
            }

            if (matchStateSubscribed && MatchState.Instance != null)
            {
                MatchState.Instance.OnPhaseChanged -= HandlePhaseChanged;
                matchStateSubscribed = false;
            }

            if (deckSubscribed && subscribedDeck != null)
            {
                subscribedDeck.OnDeckChanged -= HandleDeckChanged;
            }
            deckSubscribed = false;
            subscribedDeck = null;

            if (draftSubscribed && subscribedDraft != null)
            {
                subscribedDraft.OnDraftChanged -= HandleDraftChanged;
            }
            draftSubscribed = false;
            subscribedDraft = null;
        }

        private void TrySubscribeSelection()
        {
            if (selectionSubscribed) return;
            if (SelectionState.Instance == null) return;
            SelectionState.Instance.OnSelectionChanged += HandleSelectionChanged;
            selectionSubscribed = true;
        }

        private void Update()
        {
            if (!uiInitialized) return;

            if (!placementManagerReady)
                TryReadyPlacementManager();

            if (!matchStateSubscribed)
                TrySubscribeMatchState();

            if (!deckSubscribed)
                TrySubscribeDeck();

            if (!draftSubscribed)
                TrySubscribeDraft();

            RefreshGoldDisplay();
            RefreshMatchStateDisplays();
            UpdateBuildProgressIfShown();
            UpdateEnemyInfoIfShown();
            UpdateDraftVisibility();
            HandleChatInput();

            // Skip gameplay hotkeys while the chat input is focused — letters
            // typed into chat should not also fire Q/W/E/R tower builds.
            if (!IsTextInputActive)
                HandleHotkeys();

            minimapView?.Tick();
        }

        /// <summary>
        /// While a BuildSiteVisual is selected, refresh the progress bar's fill
        /// width and the percent label. Runs every frame so the bar tracks server
        /// time as construction advances. Cheap enough not to throttle (one width
        /// assignment, one string allocation per frame while selected).
        /// </summary>
        private void UpdateBuildProgressIfShown()
        {
            if (buildProgressContainer == null) return;

            var sel = SelectionState.Instance?.SelectedObject;
            // Use Unity's overloaded equality via cast — a destroyed BuildSiteVisual
            // still passes `is` but blows up on member access. Match the same
            // backstop pattern SelectionVisualizer uses.
            if (sel is BuildSiteVisual bsv && (UnityEngine.Object)bsv != null)
            {
                float progress = bsv.ComputeProgressNormalized();

                if (buildProgressFill != null)
                {
                    // Width is a percent of the parent bar background; multiplying
                    // by 100 keeps the StyleLength in percent units.
                    buildProgressFill.style.width =
                        new StyleLength(new Length(progress * 100f, LengthUnit.Percent));
                }

                if (buildProgressPercent != null)
                {
                    buildProgressPercent.text = $"{Mathf.RoundToInt(progress * 100f)}%";
                }
            }
        }

        /// <summary>
        /// Reads raw keyboard state via the New Input System and fires the matching
        /// action for any bound hotkey pressed this frame. Mirrors the disabled-button
        /// behaviour: a SetEnabled(false) button is skipped (so Upgrade/Sell don't
        /// trigger from the keyboard while their backing systems are still stubbed).
        /// </summary>
        private void HandleHotkeys()
        {
            var kb = Keyboard.current;
            if (kb == null) return;

            // Iterate by index — foreach over a struct list would copy each entry.
            for (int i = 0; i < hotkeyBindings.Count; i++)
            {
                var binding = hotkeyBindings[i];
                if (!kb[binding.Key].wasPressedThisFrame) continue;
                if (binding.Button == null || !binding.Button.enabledSelf) continue;
                binding.Action?.Invoke();
            }
        }

        private void OnDestroy()
        {
            if (Instance == this) Instance = null;

            minimapView?.Dispose();
            minimapView = null;

            if (s_hudPanel != null && s_hudPanel == myPanel)
                s_hudPanel = null;
            myPanel = null;
        }

        // ----- Gold display -----------------------------------------------

        private void RefreshGoldDisplay()
        {
            if (goldLabel == null) return;
            var gm = PlayerGoldManager.Local;
            goldLabel.text = gm != null ? $"{gm.CurrentGold:N0} g" : "-- g";
        }

        // ----- Match-state display ----------------------------------------

        // Polled each frame from Update. MatchState exposes Lives and CurrentWave
        // as plain int properties backed by NetworkVariables; polling avoids
        // having to add OnLivesChanged / OnCurrentWaveChanged events to MatchState
        // for every consumer that wants to display them. The cost is one int read
        // and a string allocation per frame — negligible at HUD scale.
        private void RefreshMatchStateDisplays()
        {
            var ms = MatchState.Instance;
            var wm = WaveManager.Instance;

            if (livesLabel != null)
                livesLabel.text = ms != null ? $"lives: {ms.Lives}" : "lives: --";

            if (waveLabel != null)
            {
                int total = wm?.TotalWaves ?? 0;
                waveLabel.text = ms != null && ms.CurrentWave > 0 && total > 0
                    ? $"Wave {ms.CurrentWave} / {total}"
                    : "Wave --";
            }

            // Next-wave countdown. Shows during prep ("next: 0:12") and clears the
            // moment the wave actually starts spawning. WaveManager.PrepCountdown
            // is networked so this reads the same value on every peer.
            if (nextWaveLabel != null)
            {
                float t = wm != null ? wm.PrepCountdown : 0f;
                if (t > 0f)
                {
                    // Ceiling so the user sees a full "0:01" tick before "0:00".
                    int seconds = Mathf.CeilToInt(t);
                    int mm = seconds / 60;
                    int ss = seconds % 60;
                    nextWaveLabel.text = $"next: {mm}:{ss:00}";
                }
                else
                {
                    nextWaveLabel.text = "next: --:--";
                }
            }

            // Top-bar "earned this wave" counter. Reads the LOCAL player's
            // PlayerGoldManager.GoldEarnedThisWave — which the server resets to 0 at
            // wave start and increments via AwardGold on every kill / completion /
            // no-leak bonus. Spending doesn't decrement it.
            if (incomeLabel != null)
            {
                var localGold = PlayerGoldManager.Local;
                int earned = localGold != null ? localGold.GoldEarnedThisWave : 0;
                incomeLabel.text = $"+{earned} g/wave";
            }

            // Local player's origin-leak count: how many enemies that spawned in MY
            // zone escaped my maze. Resolves the local PlayerMatchState's slot then
            // reads the per-slot counter from WaveManager (replicated NetworkList).
            if (leakedLabel != null)
            {
                var local = PlayerMatchState.Local;
                int leaks = 0;
                if (wm != null && local != null && local.Slot != PlayerSlot.None)
                    leaks = wm.GetZoneLeakCount(local.Slot);
                leakedLabel.text = $"leaked: {leaks}";
            }

            // Right-panel scoreboard rebuild — see RefreshScoreboard.
            RefreshScoreboard();
        }

        // ----- Scoreboard --------------------------------------------------

        // Snapshot of last-rebuilt scoreboard state so we only rebuild when something
        // changes. Without this we'd destroy and recreate every row every frame —
        // wasteful and (in line with LobbyController's player-list pattern) would
        // also break any per-row pointer interaction we might add later.
        private string lastScoreboardSignature = string.Empty;

        private void RefreshScoreboard()
        {
            if (playerListContainer == null) return;

            // Sort by slot for stable ordering. Counter-intuitively the underlying
            // collection isn't slot-ordered (it's keyed by NGO clientId).
            var players = new System.Collections.Generic.List<PlayerMatchState>();
            foreach (var pms in PlayerMatchState.AllPlayers) players.Add(pms);
            players.Sort((a, b) => ((int)a.Slot).CompareTo((int)b.Slot));

            // Build a signature of everything the rendered rows depend on. Skip the
            // rebuild when nothing has changed.
            string sig = ComputeScoreboardSignature(players);
            if (sig == lastScoreboardSignature) return;
            lastScoreboardSignature = sig;

            playerListContainer.Clear();
            if (players.Count == 0)
            {
                var emptyLabel = new Label("(no players)");
                emptyLabel.style.color = new Color(0.6f, 0.6f, 0.6f);
                emptyLabel.style.unityFontStyleAndWeight = FontStyle.Italic;
                playerListContainer.Add(emptyLabel);
                return;
            }

            var wm = WaveManager.Instance;
            foreach (var pms in players)
            {
                playerListContainer.Add(BuildScoreboardRow(pms, wm));
            }
        }

        private VisualElement BuildScoreboardRow(PlayerMatchState pms, WaveManager wm)
        {
            var row = new VisualElement();
            row.style.flexDirection = FlexDirection.Row;
            row.style.alignItems = Align.Center;
            row.style.marginBottom = 2;
            row.style.paddingLeft = 4;
            row.style.paddingRight = 4;

            // Layout model: three columns with explicit widths/flex so all three are
            // guaranteed visible inside the right panel. Name has flexGrow:1 so it
            // takes leftover space; gold and leaks have fixed widths and right-align
            // their text so the numbers line up vertically across rows. The previous
            // layout used flexGrow on name with justify-content space-between, which
            // pushed the leaks column off the panel's right edge on narrow widths.
            string name = string.IsNullOrEmpty(pms.DisplayName)
                ? $"P{(int)pms.Slot}"
                : pms.DisplayName;
            var nameLabel = new Label(name);
            // Tint with the canonical player-slot color — same palette used by
            // builders, minimap icons, and zone outlines for consistent identity.
            // Colors were tuned for gizmos on Unity's gray scene view; they're still
            // legible on the dark panel for all slots except P9 (dark gray), which is
            // intentionally subdued relative to the others.
            nameLabel.style.color = PlayerColors.Get(pms.Slot);
            nameLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
            nameLabel.style.fontSize = 10;
            nameLabel.style.whiteSpace = WhiteSpace.NoWrap; // keep name on one line
            nameLabel.style.flexGrow = 1; // takes all leftover width
            nameLabel.style.flexShrink = 1;
            nameLabel.style.overflow = Overflow.Hidden;
            nameLabel.style.textOverflow = TextOverflow.Ellipsis;
            row.Add(nameLabel);

            // Gold column. Read from PlayerGoldManager by clientId. May be null briefly
            // during spawn races; render "--" in that case rather than crashing.
            // Width 46px: right-aligned numbers; fits 4-digit gold totals at 10px font.
            var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId);
            string goldText = gm != null ? $"{gm.CurrentGold}" : "--";
            var goldLabel = new Label(goldText);
            goldLabel.style.color = new Color(1f, 0.85f, 0.35f); // gold-y
            goldLabel.style.fontSize = 10;
            goldLabel.style.width = 46;
            goldLabel.style.flexShrink = 0;
            goldLabel.style.unityTextAlign = TextAnchor.MiddleRight;
            row.Add(goldLabel);

            // Leaks column. Same NetworkList the local player's "leaked: N" top-bar
            // label reads — keeps the two views in sync. Always rendered (including 0)
            // so designers can see at a glance whether a player has clean runs so far.
            // Width 30px: right-aligned; 2-digit leak counts fit comfortably at 10px font.
            int leaks = (wm != null && pms.Slot != PlayerSlot.None)
                ? wm.GetZoneLeakCount(pms.Slot)
                : 0;
            var leaksLabel = new Label($"{leaks}");
            leaksLabel.style.color = leaks == 0
                ? new Color(0.55f, 0.85f, 0.55f)
                : new Color(0.95f, 0.6f, 0.4f);
            leaksLabel.style.fontSize = 10;
            leaksLabel.style.width = 30;
            leaksLabel.style.flexShrink = 0;
            leaksLabel.style.unityTextAlign = TextAnchor.MiddleRight;
            row.Add(leaksLabel);

            return row;
        }

        // Components: ordered slot sequence + their name / gold / leaks. Any change
        // triggers a rebuild. Slot count itself rarely changes mid-match (joins are
        // gated by the lobby), but the values do.
        private static readonly System.Text.StringBuilder s_scoreboardSigBuf =
            new System.Text.StringBuilder(64);

        private string ComputeScoreboardSignature(System.Collections.Generic.List<PlayerMatchState> players)
        {
            s_scoreboardSigBuf.Clear();
            var wm = WaveManager.Instance;
            foreach (var pms in players)
            {
                var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId);
                int gold = gm != null ? gm.CurrentGold : 0;
                int leaks = (wm != null && pms.Slot != PlayerSlot.None)
                    ? wm.GetZoneLeakCount(pms.Slot)
                    : 0;
                s_scoreboardSigBuf.Append((int)pms.Slot).Append(':')
                    .Append(pms.DisplayName ?? string.Empty).Append(':')
                    .Append(gold).Append(':')
                    .Append(leaks).Append(';');
            }

            return s_scoreboardSigBuf.ToString();
        }

        // ----- Command grid -----------------------------------------------

        private const int GRID_COLS = 5;
        private const int GRID_ROWS = 3;
        private const int GRID_MAX = GRID_COLS * GRID_ROWS;

        // First-time check that TowerPlacementManager exists. Once ready, populates the
        // grid for the current selection. Subsequent populates flow through
        // HandleSelectionChanged → PopulateGridForSelection.
        private void TryReadyPlacementManager()
        {
            if (placementManager == null)
                placementManager = TowerPlacementManager.Instance;

            if (placementManager == null) return; // not spawned yet — retry next frame

            placementManagerReady = true;
            PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
        }

        /// <summary>
        /// Rebuilds the command grid based on the current selection AND toggles the
        /// action frame visibility. Builder → tower-build buttons in the early slots.
        /// Tower → Upgrade in slot 0, Sell in the last slot. Anything else (Enemy,
        /// null) → action frame hidden entirely (display:none).
        /// </summary>
        private void PopulateGridForSelection(ISelectable selection)
        {
            if (commandGrid == null) return;
            if (!placementManagerReady) return; // deferred to TryReadyPlacementManager

            // Selection changed — invalidate the previous frame's hotkey bindings
            // before creating new ones. Without this, stale buttons from a previous
            // selection would keep responding to their hotkey.
            hotkeyBindings.Clear();

            // Decide whether any actions exist for this selection. The action frame
            // is hidden entirely when there are none — matches the WC3-style UX.
            bool hasActions = selection is Builder
                              || selection is TowerInstance
                              || selection is BuildSiteVisual;
            if (actionFrame != null)
                actionFrame.style.display = hasActions ? DisplayStyle.Flex : DisplayStyle.None;

            // Build/Paint tabs only make sense for the builder's build menu. For any
            // other selection (or none), reset to the Build tab and exit paint mode so
            // the cursor never gets stuck as a brush after deselecting the builder.
            bool isBuilder = selection is Builder;
            if (!isBuilder)
            {
                activeTab = CommandTab.Build;
                paintController?.CancelPaint();
            }
            if (commandTabs != null)
                commandTabs.style.display = isBuilder ? DisplayStyle.Flex : DisplayStyle.None;
            RefreshTabActiveState();

            commandGrid.Clear();
            if (!hasActions) return; // grid stays empty; frame is hidden anyway

            // Build the 15-cell action layout for the active selection kind.
            var cells = new VisualElement[GRID_MAX];

            if (selection is Builder)
            {
                if (activeTab == CommandTab.Paint)
                {
                    // Paint swatches: Red/Green/Blue then a Reset (None) brush, mapped to
                    // the Q/W/E/R hotkey slots via the shared CreateActionButton wiring.
                    cells[0] = CreatePaintButton(PaintColor.Red,   HotkeyLayout[0]);
                    cells[1] = CreatePaintButton(PaintColor.Green, HotkeyLayout[1]);
                    cells[2] = CreatePaintButton(PaintColor.Blue,  HotkeyLayout[2]);
                    cells[3] = CreatePaintButton(PaintColor.None,  HotkeyLayout[3]);
                }
                else
                {
                    // Build buttons come from the LOCAL player's deck — the per-player
                    // unlocked set — not the global catalog. Each deck entry is a
                    // TowerTypeId resolved back to its definition via the catalog. The
                    // last grid slot is reserved for the Buffs button, so towers fill
                    // slots 0..GRID_MAX-2.
                    var deck = PlayerTowerDeck.Local;
                    if (deck != null)
                    {
                        int count = Mathf.Min(deck.Count, GRID_MAX - 1);
                        for (int i = 0; i < count; i++)
                        {
                            int typeId = deck.GetTypeIdAt(i);
                            var def = TowerPlacementManager.GetDefinition(typeId);
                            if (def != null)
                                cells[i] = CreateTowerButton(def, typeId, HotkeyLayout[i]);
                        }
                    }
                }
                cells[GRID_MAX - 1] = CreateBuffMenuButton(HotkeyLayout[GRID_MAX - 1]);
            }
            else if (selection is TowerInstance tower)
            {
                // WC3 layout convention: primary action top-left (Q), sell bottom-right (B).
                cells[0] = CreateUpgradeButton(tower, HotkeyLayout[0]);
                cells[GRID_MAX - 1] = CreateSellButton(tower, HotkeyLayout[GRID_MAX - 1]);
            }
            else if (selection is BuildSiteVisual bsv)
            {
                // Cancel is the only action available on an in-progress build.
                // Placed at top-left (Q) — primary slot for a single-action menu.
                cells[0] = CreateCancelButton(bsv, HotkeyLayout[0]);
            }

            // Remaining cells become empty slots so the 5×3 grid layout is preserved.
            for (int i = 0; i < GRID_MAX; i++)
            {
                if (cells[i] == null) cells[i] = CreateEmptySlot();
            }

            for (int row = 0; row < GRID_ROWS; row++)
            {
                var rowEl = new VisualElement();
                rowEl.AddToClassList("cmd-row");
                for (int col = 0; col < GRID_COLS; col++)
                    rowEl.Add(cells[row * GRID_COLS + col]);
                commandGrid.Add(rowEl);
            }
        }

        // Tower button: clicking begins placement; hovering drives the Tool Tip.
        private VisualElement CreateTowerButton(TowerDefinition def, int typeId, Key hotkey)
        {
            var btn = CreateActionButton(
                costText: $"{def.GoldCost}g",
                hotkey:   hotkey,
                onClick:  () =>
                {
                    if (placementController != null)
                        placementController.BeginPlacement(def, typeId);
                    else
                        Debug.LogWarning("[HUDController] No TowerPlacementController assigned.");
                });

            // Show the tower's thumbnail on the icon placeholder. Falls back to the grey
            // placeholder square when no Icon is assigned (see CreatePaintButton for the pattern).
            if (def.Icon != null)
            {
                var icon = btn.Q<VisualElement>(null, "cmd-icon-placeholder");
                if (icon != null)
                    icon.style.backgroundImage = new StyleBackground(def.Icon);
            }

            btn.RegisterCallback<MouseEnterEvent>(_ => ShowTooltip(def));
            btn.RegisterCallback<MouseLeaveEvent>(_ => ClearTooltip());
            return btn;
        }

        // ----- Build/Paint tabs -------------------------------------------

        // Switch the active command-grid tab and rebuild for the current selection.
        // Leaving the Paint tab also exits paint mode so the cursor reverts.
        private void SwitchTab(CommandTab tab)
        {
            if (tab == CommandTab.Build)
                paintController?.CancelPaint();

            activeTab = tab;
            PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
        }

        private void RefreshTabActiveState()
        {
            tabBuild?.EnableInClassList("active", activeTab == CommandTab.Build);
            tabPaint?.EnableInClassList("active", activeTab == CommandTab.Paint);
        }

        // Paint swatch: clicking enters paint mode with that color (None = Reset brush).
        // Built on CreateActionButton so the Q/W/E/R hotkey wiring comes for free; the
        // icon placeholder is tinted to the swatch color.
        private VisualElement CreatePaintButton(PaintColor color, Key hotkey)
        {
            var btn = CreateActionButton(
                costText: "",
                hotkey:   hotkey,
                onClick:  () =>
                {
                    if (paintController != null)
                        paintController.BeginPaint(color);
                    else
                        Debug.LogWarning("[HUDController] No TowerPaintController assigned.");
                });

            var icon = btn.Q<VisualElement>(null, "cmd-icon-placeholder");
            if (icon != null)
                icon.style.backgroundColor = PaintColors.Get(color);

            string label = color == PaintColor.None ? "Reset" : color.ToString();
            btn.RegisterCallback<MouseEnterEvent>(_ => ShowPaintTooltip(label, color));
            btn.RegisterCallback<MouseLeaveEvent>(_ => ClearTooltip());
            return btn;
        }

        // Lightweight tooltip for paint swatches — reuses the tooltip box (title + desc).
        private void ShowPaintTooltip(string label, PaintColor color)
        {
            if (ttTitle == null) return;
            ttTitle.text = label;
            ttDesc.text  = color == PaintColor.None
                ? "Clear paint — revert tower to its owner color."
                : "Paint your towers this color.";
            ttStats.text = "";
            ttCost.text  = "";
        }

        private static VisualElement CreateEmptySlot()
        {
            var slot = new VisualElement();
            slot.AddToClassList("cmd-btn");
            slot.AddToClassList("empty-slot");
            slot.pickingMode = PickingMode.Ignore;
            return slot;
        }

        /// <summary>
        /// Builds the standard action button: a Button containing a centered icon
        /// placeholder, plus optional hotkey badge (top-left) and cost badge
        /// (bottom-left). Both badges are pure visual overlays — click events still
        /// reach the Button. The hotkey, when non-None, is registered in
        /// <see cref="hotkeyBindings"/> so Update can fire <paramref name="onClick"/>
        /// on keypress (gated on the button's <c>enabledSelf</c>).
        /// </summary>
        private Button CreateActionButton(string costText, Key hotkey, System.Action onClick)
        {
            var btn = new Button(() => onClick?.Invoke());
            btn.AddToClassList("cmd-btn");

            // Icon — added FIRST so it sits underneath the absolute-positioned badges.
            var iconPlaceholder = new VisualElement();
            iconPlaceholder.AddToClassList("cmd-icon-placeholder");
            iconPlaceholder.pickingMode = PickingMode.Ignore;
            btn.Add(iconPlaceholder);

            // Hotkey badge — top-left.
            if (hotkey != Key.None)
            {
                var hkLabel = new Label(KeyToDisplay(hotkey));
                hkLabel.AddToClassList("cmd-hotkey");
                hkLabel.pickingMode = PickingMode.Ignore;
                btn.Add(hkLabel);
                hotkeyBindings.Add(new HotkeyBinding(hotkey, btn, onClick));
            }

            // Cost badge — bottom-left. Omitted when no cost is meaningful (e.g., Upgrade
            // before the upgrade system has a tier-cost lookup).
            if (!string.IsNullOrEmpty(costText))
            {
                var costLabel = new Label(costText);
                costLabel.AddToClassList("cmd-cost");
                costLabel.pickingMode = PickingMode.Ignore;
                btn.Add(costLabel);
            }

            return btn;
        }

        // Upgrade and Sell — visuals + hotkeys wired; click is a no-op because the
        // upgrade/sell systems aren't built yet. Buttons are SetEnabled(false) so the
        // hotkey handler also skips them (it gates on enabledSelf).
        private VisualElement CreateUpgradeButton(TowerInstance tower, Key hotkey)
        {
            var btn = CreateActionButton(
                costText: "", // tier cost unknown until upgrade system lands
                hotkey: hotkey,
                onClick: () =>
                {
                    /* TODO: upgrade flow */
                });
            btn.SetEnabled(false);
            return btn;
        }

        private VisualElement CreateSellButton(TowerInstance tower, Key hotkey)
        {
            int sellValue = tower.Definition != null
                ? Mathf.RoundToInt(tower.Definition.GoldCost * 0.7f)
                : 0;
            var btn = CreateActionButton(
                costText: sellValue > 0 ? $"+{sellValue}g" : "",
                hotkey: hotkey,
                onClick: () =>
                {
                    /* TODO: sell flow */
                });
            btn.SetEnabled(false);
            return btn;
        }

        private VisualElement CreateBuffMenuButton(Key hotkey)
        {
            // Key.None so HandleHotkeys doesn't register B here — BuilderInputController
            // already fires ToggleBuffMenu on B globally. Registering it in both places
            // causes a double-toggle on the same frame when the builder is selected.
            return CreateActionButton(
                costText: "Buffs  [B]",
                hotkey:   Key.None,
                onClick:  () => ToggleBuffMenu());
        }

        // Cancel action for an in-progress build. Fires the owner-only RPC; the
        // server cancels the matching job (or, for shelved sites, refunds + despawns
        // directly), full gold is refunded, the BuildSiteVisual is despawned, and
        // OnSelectionChanged fires with null — HUD/visualizer/ring all clear
        // automatically.
        private VisualElement CreateCancelButton(BuildSiteVisual bsv, Key hotkey)
        {
            int refund = bsv.GoldSpent;
            return CreateActionButton(
                costText: refund > 0 ? $"+{refund}g" : "",
                hotkey:   hotkey,
                onClick:  () => bsv.RequestCancelRpc());
        }

        // Renders a Key as a single-character badge ("Q", "1", etc.). Letter and number
        // keys produce their own glyph via ToString; if we ever bind non-letter keys
        // (e.g., F1 or Space), extend this with a mapping table.
        private static string KeyToDisplay(Key key) => key.ToString();

        // ----- Portrait / selection context ----------------------------------

        /// <summary>
        /// Called by the selection system when a unit is selected or deselected.
        /// Pass null or empty to clear (nothing selected).
        /// </summary>
        public void SetSelectedUnitName(string unitName)
        {
            if (portraitName == null) return;
            portraitName.text = string.IsNullOrEmpty(unitName) ? "" : unitName;
        }

        private void HandleSelectionChanged(ISelectable selection)
        {
            // Sections 2/3/4 (portrait, info, tooltip) stay visible regardless;
            // their *contents* update based on selection. Section 5 (action menu)
            // hides via PopulateGridForSelection when there are no actions.
            PopulateInfoPanel(selection);
            PopulateGridForSelection(selection);
        }

        /// <summary>
        /// Drives the portrait name (section 3 header), level (section 2 footer),
        /// contextual stat lines (section 3 body), and the build-progress sub-view
        /// (section 3 — shown only when a BuildSiteVisual is selected).
        /// </summary>
        private void PopulateInfoPanel(ISelectable selection)
        {
            // Name
            SetSelectedUnitName(selection?.DisplayName);

            // Level — placeholder until upgrade system lands.
            //   Builder → "Lv. 1" (per design; may carry experience later)
            //   Tower   → "Lv. 1" (will reflect tower upgrade tier when implemented)
            //   Nothing → blank
            if (levelLabel != null)
                levelLabel.text = selection != null ? "Lv. 1" : "";

            // Build-progress block visibility: shown only for BuildSiteVisual.
            // Stat lines remain underneath and just stay empty for that case.
            bool isBuildSite = selection is BuildSiteVisual;
            if (buildProgressContainer != null)
                buildProgressContainer.style.display =
                    isBuildSite ? DisplayStyle.Flex : DisplayStyle.None;

            // Stat lines — clear and rebuild based on selection kind.
            // The enemy-info cached references (health bar + label) are invalidated
            // by Clear() and re-cached if a new Enemy gets selected below.
            enemyHealthBar  = null;
            enemyHealthFill = null;
            enemyHealthText = null;

            if (statLines != null)
            {
                statLines.Clear();
                if (selection is Builder builder)
                {
                    AddStatLine($"Build range: {builder.BuildRange:0.0}");
                }
                else if (selection is TowerInstance tower)
                {
                    var def = tower.Definition;
                    if (def != null)
                    {
                        if (def.Damage > 0) AddStatLine($"Damage: {def.Damage}");
                        if (def.Range > 0) AddStatLine($"Range: {def.Range:0.0}");
                        if (def.FireRate > 0) AddStatLine($"Fire rate: {def.FireRate:0.0}/s");
                        if (def.SlowFactor < 1f)
                            AddStatLine($"Slow: {(1f - def.SlowFactor) * 100f:0}%");
                    }
                }
                else if (selection is EnemyHealth enemy)
                {
                    BuildEnemyInfo(enemy);
                }
                // BuildSiteVisual: no stat lines — progress bar conveys the state.
            }
        }

        // ----- Enemy info -------------------------------------------------

        /// <summary>
        /// Builds the enemy info stat block (HP bar + speed + bounty) inside the
        /// stat-lines container. The bar and label references are cached so
        /// <see cref="UpdateEnemyInfoIfShown"/> can drive them every frame as the
        /// enemy takes damage.
        /// </summary>
        private void BuildEnemyInfo(EnemyHealth enemy)
        {
            var def = enemy.Definition;

            // ----- Health bar (X / Y) -----
            // A thin horizontal bar with a green fill and a centered "X / Y" label
            // on top. Styled with inline-style so we don't need to add USS classes
            // — keeps this self-contained.
            var hpRow = new VisualElement();
            hpRow.style.flexDirection = FlexDirection.Row;
            hpRow.style.alignItems    = Align.Center;
            hpRow.style.marginTop     = 4;
            hpRow.style.marginBottom  = 4;

            var hpLabel = new Label("HP");
            hpLabel.style.minWidth = 28;
            hpLabel.style.color    = new Color(0.85f, 0.85f, 0.85f);
            hpRow.Add(hpLabel);

            // Background + fill share a parent so the fill can be styled as a
            // percentage width inside the background.
            var hpBackground = new VisualElement();
            hpBackground.style.flexGrow        = 1;
            hpBackground.style.height          = 14;
            hpBackground.style.backgroundColor = new Color(0.1f, 0.1f, 0.1f, 0.85f);
            hpBackground.style.borderTopWidth = hpBackground.style.borderBottomWidth =
                hpBackground.style.borderLeftWidth = hpBackground.style.borderRightWidth = 1;
            var borderColor = new Color(0.3f, 0.3f, 0.3f);
            hpBackground.style.borderTopColor = hpBackground.style.borderBottomColor =
                hpBackground.style.borderLeftColor = hpBackground.style.borderRightColor = borderColor;

            var hpFill = new VisualElement();
            hpFill.style.height          = Length.Percent(100);
            hpFill.style.width           = Length.Percent(100);
            hpFill.style.backgroundColor = new Color(0.3f, 0.85f, 0.3f);
            hpFill.pickingMode           = PickingMode.Ignore;
            hpBackground.Add(hpFill);

            // Overlay "X / Y" label, centered above the bar.
            var hpText = new Label();
            hpText.pickingMode             = PickingMode.Ignore;
            hpText.style.position          = Position.Absolute;
            hpText.style.left              = 0;
            hpText.style.right             = 0;
            hpText.style.top               = 0;
            hpText.style.bottom            = 0;
            hpText.style.unityTextAlign    = TextAnchor.MiddleCenter;
            hpText.style.color             = Color.white;
            hpText.style.fontSize          = 11;
            hpBackground.Add(hpText);

            hpRow.Add(hpBackground);
            statLines.Add(hpRow);

            // Cache references for per-frame updates.
            enemyHealthBar  = hpBackground;
            enemyHealthFill = hpFill;
            enemyHealthText = hpText;

            // Initial value (will be refreshed every frame in UpdateEnemyInfoIfShown).
            UpdateEnemyHpDisplay(enemy);

            // ----- Speed + Bounty stat lines -----
            if (def != null)
            {
                AddStatLine($"Speed: {def.MoveSpeed:0.0}");
                // Bounty is per-wave now (GoldConfig.Waves[N].GoldPerEnemy) rather than
                // per-enemy-type. Read the current wave's value so the tooltip is accurate.
                var wm = WaveManager.Instance;
                int currentWave = MatchState.Instance != null ? MatchState.Instance.CurrentWave : 0;
                var goldEntry = wm?.GoldConfig?.GetWaveEntry(currentWave);
                if (goldEntry != null)
                    AddStatLine($"Bounty: {goldEntry.GoldPerEnemy} g");
                // (Weaknesses/resistances will go here once the resistance system lands.)
            }
        }

        private void UpdateEnemyInfoIfShown()
        {
            if (enemyHealthFill == null) return; // no Enemy selected
            var sel = SelectionState.Instance?.SelectedObject;
            if (sel is EnemyHealth enemy && (UnityEngine.Object)enemy != null)
                UpdateEnemyHpDisplay(enemy);
        }

        private void UpdateEnemyHpDisplay(EnemyHealth enemy)
        {
            float cur = Mathf.Max(0f, enemy.CurrentHp);
            float max = Mathf.Max(1f, enemy.MaxHp);
            float pct = Mathf.Clamp01(cur / max);

            if (enemyHealthFill != null)
                enemyHealthFill.style.width = Length.Percent(pct * 100f);
            if (enemyHealthText != null)
                enemyHealthText.text = $"{Mathf.CeilToInt(cur)} / {Mathf.CeilToInt(max)}";
        }

        private void AddStatLine(string text)
        {
            var label = new Label(text);
            label.AddToClassList("stat-line");
            statLines.Add(label);
        }

        // ----- Tooltip ----------------------------------------------------

        private void ShowTooltip(TowerDefinition def)
        {
            if (ttTitle == null) return;

            ttTitle.text = def.DisplayName;
            ttDesc.text  = def.Description ?? "";

            if (def.Damage > 0 || def.Range > 0)
            {
                ttStats.text = $"DMG: {def.Damage}  ·  Range: {def.Range:0}";
                if (def.FireRate > 0)
                    ttStats.text += $"\nFire rate: {def.FireRate:0.0}/s";
                if (def.SlowFactor < 1f)
                    ttStats.text += $"\nSlow: {(1f - def.SlowFactor) * 100f:0}%";
            }
            else
            {
                ttStats.text = "(stats pending)";
            }

            int sellValue = Mathf.RoundToInt(def.GoldCost * 0.7f);
            ttCost.text = $"Cost: {def.GoldCost}g  ·  Sell: {sellValue}g";
        }

        private void ClearTooltip()
        {
            if (ttTitle == null) return;
            ttTitle.text = "";
            ttDesc.text  = "";
            ttStats.text = "";
            ttCost.text  = "";
        }

        // ----- Rejection messages -----------------------------------------

        private void ShowRejectionMessage(string message)
        {
            if (rejectionLabel == null) return;

            rejectionLabel.text = message;
            rejectionLabel.style.display = DisplayStyle.Flex;

            if (rejectionFadeCoroutine != null)
                StopCoroutine(rejectionFadeCoroutine);

            rejectionFadeCoroutine = StartCoroutine(HideRejectionAfterDelay());
        }

        private IEnumerator HideRejectionAfterDelay()
        {
            yield return new WaitForSeconds(rejectionMessageDuration);
            if (rejectionLabel != null)
                rejectionLabel.style.display = DisplayStyle.None;
            rejectionFadeCoroutine = null;
        }

        // ----- Portrait click: center camera on selection -----------------

        // WC3 / SC2 convention: clicking the portrait recenters the camera on
        // whoever is selected. Zoom is preserved (CameraController.JumpTo only
        // moves the pivot). No-op when nothing is selected or the camera isn't wired.
        private void OnPortraitClicked(ClickEvent evt)
        {
            var sel = SelectionState.Instance?.SelectedObject;
            if (sel == null) return;
            if (cameraController == null) return;

            var t = sel.SelectionTransform;
            if (t == null) return;

            cameraController.JumpTo(t.position);
        }

        // ----- Chat feed + input -----------------------------------------

        // ----- Buff menu overlay ------------------------------------------

        private void BuildBuffMenuOverlay(VisualElement root)
        {
            buffMenuOverlay = new VisualElement();
            buffMenuOverlay.style.position        = Position.Absolute;
            buffMenuOverlay.style.left            = 0;
            buffMenuOverlay.style.right           = 0;
            buffMenuOverlay.style.top             = 0;
            buffMenuOverlay.style.bottom          = 0;
            buffMenuOverlay.style.alignItems      = Align.Center;
            buffMenuOverlay.style.justifyContent  = Justify.Center;
            buffMenuOverlay.style.backgroundColor = new Color(0f, 0f, 0f, 0.5f);
            buffMenuOverlay.style.display         = DisplayStyle.None;
            buffMenuOverlay.pickingMode           = PickingMode.Position;

            var panel = new VisualElement();
            panel.style.minWidth        = 340;
            panel.style.paddingTop      = 20;
            panel.style.paddingBottom   = 20;
            panel.style.paddingLeft     = 28;
            panel.style.paddingRight    = 28;
            panel.style.backgroundColor = new Color(0.08f, 0.08f, 0.10f, 0.95f);
            panel.style.borderTopWidth  = panel.style.borderBottomWidth =
                panel.style.borderLeftWidth = panel.style.borderRightWidth = 2;
            var border = new Color(0.4f, 0.4f, 0.45f);
            panel.style.borderTopColor  = panel.style.borderBottomColor =
                panel.style.borderLeftColor = panel.style.borderRightColor = border;

            var title = new Label("Buffs");
            title.style.fontSize    = 24;
            title.style.color       = Color.white;
            title.style.unityFontStyleAndWeight = FontStyle.Bold;
            title.style.marginBottom = 12;
            panel.Add(title);

            // Scrollable content area: current buffs + purchase buttons.
            // Rebuilt each time the menu is shown via RefreshBuffMenuContent().
            buffMenuContent = new VisualElement();
            panel.Add(buffMenuContent);

            var closeBtn = new Button(() => SetBuffMenuVisible(false)) { text = "Close  [B]" };
            closeBtn.style.marginTop = 16;
            closeBtn.style.height    = 32;
            panel.Add(closeBtn);

            buffMenuOverlay.Add(panel);
            root.Add(buffMenuOverlay);
        }

        /// <summary>
        /// Toggles the buff menu overlay. Called by <see cref="TD.Gameplay.BuilderInputController"/>
        /// when the player presses B.
        /// </summary>
        public void ToggleBuffMenu()
        {
            bool nowVisible = buffMenuOverlay?.style.display == DisplayStyle.None;
            SetBuffMenuVisible(nowVisible);
        }

        private void SetBuffMenuVisible(bool visible)
        {
            if (buffMenuOverlay == null) return;
            buffMenuOverlay.style.display = visible ? DisplayStyle.Flex : DisplayStyle.None;
            if (visible) RefreshBuffMenuContent();
        }

        private void RefreshBuffMenuContent()
        {
            if (buffMenuContent == null) return;
            buffMenuContent.Clear();

            var buffManager = TD.Gameplay.PlayerBuffManager.Local;

            // Current buffs section.
            var buffsHeader = new Label("Active Buffs");
            buffsHeader.style.color      = new Color(0.7f, 0.9f, 0.7f);
            buffsHeader.style.fontSize   = 14;
            buffsHeader.style.marginBottom = 4;
            buffMenuContent.Add(buffsHeader);

            if (buffManager == null || buffManager.Buffs.Count == 0)
            {
                var none = new Label("None");
                none.style.color       = new Color(0.55f, 0.55f, 0.55f);
                none.style.marginBottom = 8;
                buffMenuContent.Add(none);
            }
            else
            {
                for (int i = 0; i < buffManager.Buffs.Count; i++)
                {
                    var buff = buffManager.Buffs[i];
                    string statName = buff.Stat == TD.Core.BuffStat.Damage ? "Damage" : "Attack Speed";
                    string activeTag = buff.IsActive ? "" : " [DISABLED]";
                    var row = new Label($"• {buff.DisplayName}  ({statName} ×{buff.Multiplier:F2}){activeTag}");
                    row.style.color      = buff.IsActive ? Color.white : new Color(0.5f, 0.5f, 0.5f);
                    row.style.fontSize   = 13;
                    buffMenuContent.Add(row);
                }
                buffMenuContent.style.marginBottom = 12;
            }

            // Purchase buttons section.
            var buyHeader = new Label("Purchase");
            buyHeader.style.color        = new Color(0.9f, 0.8f, 0.5f);
            buyHeader.style.fontSize     = 14;
            buyHeader.style.marginTop    = 8;
            buyHeader.style.marginBottom = 4;
            buffMenuContent.Add(buyHeader);

            int categoryCount = buffManager?.CategoryCount ?? 0;
            if (categoryCount == 0)
            {
                var none = new Label("No categories available.");
                none.style.color = new Color(0.55f, 0.55f, 0.55f);
                buffMenuContent.Add(none);
                return;
            }

            int localGold = TD.Gameplay.PlayerGoldManager.Local?.CurrentGold ?? 0;
            for (int i = 0; i < categoryCount; i++)
            {
                var category = buffManager.GetCategory(i);
                if (category == null) continue;

                int idx = i; // capture for lambda
                var btn = new Button(() =>
                {
                    TD.Gameplay.PlayerBuffManager.Local?.RequestPurchaseBuffRpc(idx);
                    RefreshBuffMenuContent();
                })
                {
                    text = $"{category.DisplayName}  —  {category.Cost}g"
                };
                btn.style.height     = 34;
                btn.style.fontSize   = 13;
                btn.style.marginBottom = 4;
                btn.SetEnabled(localGold >= category.Cost);
                buffMenuContent.Add(btn);
            }
        }

        // Bottom-left chat panel. Anchored 12px from the left edge, with the
        // bottom edge sitting above the 220px bottom-ui. Layout uses a flex
        // column: scrollable feed on top, input below. The feed clips at
        // chatMaxHeight; messages above that scroll off the top of the visible
        // area but stay in history (visible by scrolling once chat is open).
        private void BuildChatPanel(VisualElement root)
        {
            const float bottomUiHeight = 220f;
            const float gap            = 8f;
            const float chatWidth      = 380f;
            const float inputMinHeight = 32f;
            const float inputFeedGap   = 6f;   // breathing room between feed and input

            // Container anchored to the bottom-left, growing upward as content
            // grows (no top/height set → height auto-fits content).
            chatContainer = new VisualElement();
            chatContainer.pickingMode        = PickingMode.Ignore; // closed = wheel falls through
            chatContainer.style.position     = Position.Absolute;
            chatContainer.style.left         = 12;
            chatContainer.style.bottom       = bottomUiHeight + gap;
            chatContainer.style.width        = chatWidth;
            chatContainer.style.flexDirection = FlexDirection.Column;
            chatContainer.style.paddingTop    = 4;
            chatContainer.style.paddingBottom = 4;
            chatContainer.style.paddingLeft   = 6;
            chatContainer.style.paddingRight  = 6;

            // Feed: ScrollView so older messages can scroll off the top of the
            // visible window while staying in history. Vertical-only.
            chatFeed = new ScrollView(ScrollViewMode.Vertical);
            chatFeed.style.maxHeight  = chatMaxHeight;
            chatFeed.style.flexShrink = 1;
            chatFeed.pickingMode                 = PickingMode.Ignore;
            chatFeed.contentViewport.pickingMode = PickingMode.Ignore;
            chatFeed.contentContainer.pickingMode = PickingMode.Ignore;
            chatFeed.verticalScrollerVisibility = ScrollerVisibility.Hidden;

            // Belt-and-suspenders wheel interceptor. The recursive .hierarchy
            // pickingMode walk should already prevent wheel events from reaching
            // chatFeed when chat is closed; this handler is defense-in-depth
            // against future Unity versions reorganizing ScrollView internals.
            chatFeed.RegisterCallback<WheelEvent>(evt =>
            {
                if (!chatInputOpen)
                    evt.StopImmediatePropagation();
            }, TrickleDown.TrickleDown);
            // Track-style — make the scrollbar slim and dark so it doesn't compete with messages.
            chatFeed.style.marginBottom = inputFeedGap;
            chatContainer.Add(chatFeed);

            // Input field. Hidden by default. We use minHeight (not a fixed height)
            // because the inner unity-text-input element needs vertical padding
            // for the font to render fully — pinning to 28px clipped descenders
            // and ascenders. We also style the inner child directly because the
            // TextField's visible white background lives on it, not on the root.
            chatInput = new TextField();
            chatInput.style.minHeight   = inputMinHeight;
            chatInput.style.width       = Length.Percent(100);
            chatInput.maxLength         = 120;
            chatInput.style.display     = DisplayStyle.None;
            chatInput.isDelayed         = false;
            StyleChatInputDark(chatInput);

            // Focus tracking — gameplay input gates on IsTextInputActive.
            chatInput.RegisterCallback<FocusInEvent>(_  => IsTextInputActive = true);
            chatInput.RegisterCallback<FocusOutEvent>(_ => IsTextInputActive = false);

            // Submit (Enter) and cancel (Escape) are handled in Update via
            // direct Input System reads — not via UI Toolkit's NavigationSubmit
            // event, which fires inconsistently relative to TextField.value
            // commit timing. See HandleChatInput.

            chatContainer.Add(chatInput);

            root.Add(chatContainer);

            // Initial state is closed → every chat descendant (including the
            // ScrollView's internal scroll-container wrapper) starts Ignore so
            // panel.Pick falls through to nothing and the camera owns the wheel.
            SetPickingModeRecursive(chatContainer, PickingMode.Ignore);
        }

        // Recursively sets pickingMode on every visual descendant of root.
        //
        // IMPORTANT: this uses .hierarchy[i] / .hierarchy.childCount, NOT the
        // root[i] indexer. VisualElement has two hierarchies:
        //
        //   * Logical hierarchy (root[i] / root.childCount) — children added by
        //     user code via Add(). For elements with a redirected contentContainer
        //     (ScrollView, Foldout, etc.), this only enumerates the user content,
        //     not the internal scaffolding.
        //
        //   * Visual hierarchy (root.hierarchy[i]) — the actual visual tree
        //     including internal scaffolding (ScrollView's
        //     unity-content-and-vertical-scroll-container, viewport, scrollers,
        //     etc.).
        //
        // We need the visual hierarchy because the wrapper element
        // unity-content-and-vertical-scroll-container is what panel.Pick was
        // landing on. Walking the logical hierarchy would visit ScrollView's
        // user-added chat lines but skip the internal wrapper entirely.
        private static void SetPickingModeRecursive(VisualElement root, PickingMode mode)
        {
            if (root == null) return;
            root.pickingMode = mode;
            int count = root.hierarchy.childCount;
            for (int i = 0; i < count; i++)
                SetPickingModeRecursive(root.hierarchy[i], mode);
        }

        // The TextField root color isn't where the visible background lives — it's
        // on the inner "unity-text-input" element. Style both: the visible
        // background goes dark, the text goes white so what the player types is
        // legible against it, and the inner element gets a few px of vertical
        // padding so characters with ascenders/descenders (capital letters,
        // lowercase y/g/p/q/j, "?") don't get clipped.
        private static void StyleChatInputDark(TextField field)
        {
            var darkBg     = new Color(0.10f, 0.10f, 0.10f, 0.95f);
            var borderClr  = new Color(0.45f, 0.45f, 0.5f);

            field.style.backgroundColor   = darkBg;
            field.style.color             = Color.white;
            field.style.borderTopWidth    = field.style.borderBottomWidth =
                field.style.borderLeftWidth = field.style.borderRightWidth = 1;
            field.style.borderTopColor    = field.style.borderBottomColor =
                field.style.borderLeftColor = field.style.borderRightColor = borderClr;

            // The actual editable area child. It exists immediately on construction.
            var inner = field.Q("unity-text-input");
            if (inner != null)
            {
                inner.style.backgroundColor = darkBg;
                inner.style.color           = Color.white;
                inner.style.paddingTop      = 4;
                inner.style.paddingBottom   = 4;
                inner.style.paddingLeft     = 6;
                inner.style.paddingRight    = 6;
            }
        }

        // Called every frame from Update. Handles all three chat key bindings
        // via direct Input System reads:
        //   - Enter (chat closed)  → open input
        //   - Enter (chat open)    → submit + close
        //   - Escape (chat open)   → close without submit
        //
        // Reading the Input System directly avoids two UI Toolkit quirks:
        //   1. The first Enter inside a focused TextField is sometimes consumed
        //      by the field's internal edit-mode handler before NavigationSubmit
        //      can fire, requiring a SECOND Enter to trigger the user-visible
        //      submit. Going through Keyboard.current sidesteps that pipeline.
        //   2. KeyDownEvent / NavigationSubmitEvent fire timing isn't aligned
        //      with TextField.value commit on every Unity version. Reading the
        //      Input System happens at a deterministic point in Update where
        //      TextField.value is already up to date (since isDelayed = false).
        private void HandleChatInput()
        {
            if (chatInput == null) return;

            var kb = Keyboard.current;
            if (kb == null) return;

            // Suppression window covers the frame after open/close so the same
            // keypress that toggled chat doesn't immediately fire the opposite path.
            if (Time.frameCount <= chatToggleSuppressFrame) return;

            bool enterDown = kb.enterKey.wasPressedThisFrame
                          || kb.numpadEnterKey.wasPressedThisFrame;
            bool escDown   = kb.escapeKey.wasPressedThisFrame;

            if (!chatInputOpen)
            {
                if (enterDown) OpenChatInput();
                return;
            }

            // Chat is open.
            if (enterDown) SubmitChatInput();
            else if (escDown) CloseChatInput(submit: false);
        }

        private void OpenChatInput()
        {
            if (chatInput == null) return;
            chatInput.style.display = DisplayStyle.Flex;
            chatInput.SetValueWithoutNotify(string.Empty);

            // Switch the chat panel from "passive display" to "interactive":
            //   - Container gets a dark translucent background as a visual cue
            //     AND becomes pointer-active so clicks on the highlight don't
            //     fall through and place towers / deselect units underneath.
            //   - Feed becomes pointer-active so wheel events scroll it (and
            //     so IsPointerOverInteractiveHud picks the chat for camera-gate).
            //   - Scrollbar becomes visible.
            if (chatContainer != null)
                chatContainer.style.backgroundColor = new Color(0f, 0f, 0f, 0.40f);
            if (chatFeed != null)
                chatFeed.verticalScrollerVisibility = ScrollerVisibility.Auto;

            // Make EVERY descendant of the chat panel interactive. Setting Position
            // recursively covers ScrollView's internal scroll-container wrapper
            // (which our previous explicit list of three pickingMode targets
            // missed), so panel.Pick will reliably land on a chat element and
            // wheel events will reach the ScrollView's manipulator.
            SetPickingModeRecursive(chatContainer, PickingMode.Position);

            // Suppress Enter for this frame and the next so the keypress that
            // opened chat doesn't also submit it empty. Focus is deferred a frame
            // for the same reason — UI Toolkit would otherwise route the open-Enter
            // to the freshly-focused TextField immediately.
            chatToggleSuppressFrame = Time.frameCount + 1;
            chatInputOpen = true;
            StartCoroutine(FocusChatNextFrame());
        }

        private IEnumerator FocusChatNextFrame()
        {
            yield return null;
            if (chatInputOpen && chatInput != null)
            {
                chatInput.Focus();
                // Focus alone doesn't always activate the text caret immediately.
                // SelectAll forces the field into edit mode reliably across versions.
                chatInput.SelectAll();
            }
        }

        private void CloseChatInput(bool submit)
        {
            if (chatInput == null) return;
            if (submit) SubmitChatInput();
            RevertChatPanelToPassive();
        }

        private void SubmitChatInput()
        {
            string text = chatInput?.value ?? string.Empty;
            if (!string.IsNullOrWhiteSpace(text) && ChatService.Instance != null)
                ChatService.Instance.SubmitMessage(text);

            RevertChatPanelToPassive();
        }

        // Single source of truth for "chat is no longer in typing mode" — clears the
        // input, hides it, restores pickingMode = Ignore on every layer that flipped
        // to Position on Open, drops the highlight background, and flips the debug
        // border back to red. Both Submit and Close route through here so the two
        // close paths can't drift out of sync (which is exactly what caused the
        // "border stays green / wheel keeps scrolling" bug).
        private void RevertChatPanelToPassive()
        {
            if (chatInput != null)
            {
                chatInput.SetValueWithoutNotify(string.Empty);
                chatInput.style.display = DisplayStyle.None;
                chatInput.Blur();
            }

            if (chatContainer != null)
                chatContainer.style.backgroundColor = StyleKeyword.Initial;
            if (chatFeed != null)
                chatFeed.verticalScrollerVisibility = ScrollerVisibility.Hidden;

            // Same as Open's recursive Position, but Ignore — covers ScrollView's
            // internal scroll-container wrapper so panel.Pick falls through to
            // whatever's behind chat (which is nothing, so IsPointerOverInteractiveHud
            // returns false and the camera processes wheel events normally).
            SetPickingModeRecursive(chatContainer, PickingMode.Ignore);

            chatInputOpen = false;
            chatToggleSuppressFrame = Time.frameCount + 1;
        }

        // Subscribed to ChatService.OnMessageReceived. Appends a new line to
        // the feed, prunes the oldest if we exceed the history cap, and
        // auto-scrolls only if the player was already viewing the latest
        // messages — preserving their position if they've scrolled up to
        // read older history.
        private void HandleChatMessage(ChatService.ChatEntry entry)
        {
            if (chatFeed == null) return;

            // Capture "was the user at the bottom" BEFORE we append, while the
            // layout still reflects the pre-message state. If they were, we
            // re-pin to the new bottom after the message lays out. If they
            // weren't (scrolled up reading history), we leave their position
            // alone so new messages don't yank them back.
            bool wasAtBottom = !chatInputOpen || IsChatScrolledToBottom();

            var line = BuildChatLine(entry);
            chatFeed.Add(line);

            // Bound history. ScrollView.contentContainer is what actually holds
            // child elements (the ScrollView root has its own layout chrome).
            // Default chatMaxMessages is effectively unlimited so this loop is
            // a no-op in normal play; the cap exists as a safety valve.
            var content = chatFeed.contentContainer;
            while (content.childCount > chatMaxMessages)
                content.RemoveAt(0);

            if (!wasAtBottom) return;

            // Defer scroll-to-bottom until layout has resolved the new line's
            // height — without the delay, contentContainer.layout.height is
            // stale and we'd scroll to the previous max.
            chatFeed.schedule.Execute(() =>
            {
                if (chatFeed == null) return;
                var height = chatFeed.contentContainer.layout.height;
                chatFeed.scrollOffset = new Vector2(0, Mathf.Max(0, height));
            }).ExecuteLater(1);
        }

        // True if the feed isn't overflowing yet, or the player's scroll
        // position is within a few pixels of the bottom edge of the content.
        // Used to decide whether to auto-scroll on new messages.
        private bool IsChatScrolledToBottom()
        {
            if (chatFeed == null) return true;

            float viewportHeight = chatFeed.contentViewport.layout.height;
            float contentHeight  = chatFeed.contentContainer.layout.height;

            // Not overflowing → effectively "at the bottom" (everything visible).
            if (contentHeight <= viewportHeight + 0.5f) return true;

            float maxScroll = contentHeight - viewportHeight;
            const float tolerance = 4f;
            return chatFeed.scrollOffset.y >= maxScroll - tolerance;
        }

        private Label BuildChatLine(ChatService.ChatEntry entry)
        {
            var line = new Label();
            line.pickingMode  = PickingMode.Ignore;
            line.style.marginBottom = 2;
            line.style.color  = entry.Kind == ChatService.MessageKind.System
                ? chatSystemColor
                : chatPlayerColor;
            line.style.whiteSpace = WhiteSpace.Normal; // wrap long messages

            // Soft drop shadow so messages stay readable over varied backgrounds.
            line.style.textShadow = new TextShadow
            {
                offset     = new Vector2(1, 1),
                blurRadius = 2,
                color      = new Color(0f, 0f, 0f, 0.85f),
            };

            line.text = entry.Kind == ChatService.MessageKind.System
                ? entry.Text
                : $"[{entry.SenderName}] {entry.Text}";

            return line;
        }

        // Fires every time WaveManager.OnLifeLost is invoked (one event per leak).
        // Routes the notification through ChatService as a SYSTEM message so it
        // shares the feed with player chat. The lives counter in the top bar
        // still updates independently via MatchState replication.
        private void HandleLifeLost(int amount)
        {
            string text = amount == 1 ? "1 Life Lost" : $"{amount} Lives Lost";
            ChatService.PostLocalSystem(text);
        }

        // ----- Match-end overlay (Victory / Defeat + Retry) ---------------

        private void BuildMatchEndOverlay(VisualElement root)
        {
            matchEndOverlay = new VisualElement();
            matchEndOverlay.style.position        = Position.Absolute;
            matchEndOverlay.style.left            = 0;
            matchEndOverlay.style.right           = 0;
            matchEndOverlay.style.top             = 0;
            matchEndOverlay.style.bottom          = 0;
            matchEndOverlay.style.alignItems      = Align.Center;
            matchEndOverlay.style.justifyContent  = Justify.Center;
            matchEndOverlay.style.backgroundColor = new Color(0f, 0f, 0f, 0.6f);
            matchEndOverlay.style.display         = DisplayStyle.None;
            // The overlay swallows pointer events so the player can't click towers
            // or builders through it while it's visible.
            matchEndOverlay.pickingMode           = PickingMode.Position;

            var panel = new VisualElement();
            panel.style.minWidth         = 320;
            panel.style.paddingTop       = 24;
            panel.style.paddingBottom    = 24;
            panel.style.paddingLeft      = 32;
            panel.style.paddingRight     = 32;
            panel.style.backgroundColor  = new Color(0.08f, 0.08f, 0.10f, 0.95f);
            panel.style.borderTopWidth   = panel.style.borderBottomWidth =
                panel.style.borderLeftWidth = panel.style.borderRightWidth = 2;
            var border = new Color(0.4f, 0.4f, 0.45f);
            panel.style.borderTopColor   = panel.style.borderBottomColor =
                panel.style.borderLeftColor = panel.style.borderRightColor = border;
            panel.style.alignItems       = Align.Center;

            matchEndTitle = new Label("Victory");
            matchEndTitle.style.fontSize    = 32;
            matchEndTitle.style.color       = Color.white;
            matchEndTitle.style.marginBottom = 16;
            matchEndTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
            panel.Add(matchEndTitle);

            // Action row — Retry (back to lobby with everyone who retried)
            // and Return to Main Menu (this player only disconnects).
            var actionRow = new VisualElement();
            actionRow.style.flexDirection = FlexDirection.Row;
            actionRow.style.marginTop     = 8;
            panel.Add(actionRow);

            var retryBtn = new Button(OnRetryClicked) { text = "Retry" };
            retryBtn.style.minWidth      = 140;
            retryBtn.style.height        = 36;
            retryBtn.style.fontSize      = 16;
            retryBtn.style.marginRight   = 12;
            actionRow.Add(retryBtn);

            var menuBtn = new Button(OnReturnToMainMenuClicked) { text = "Return to Main Menu" };
            menuBtn.style.minWidth      = 200;
            menuBtn.style.height        = 36;
            menuBtn.style.fontSize      = 16;
            actionRow.Add(menuBtn);

            matchEndOverlay.Add(panel);
            root.Add(matchEndOverlay);
        }

        private void HandlePhaseChanged(MatchPhase previous, MatchPhase next)
        {
            if (matchEndOverlay == null) return;

            switch (next)
            {
                case MatchPhase.Victory:
                    if (matchEndTitle != null)
                    {
                        matchEndTitle.text = "Victory";
                        matchEndTitle.style.color = new Color(1f, 0.84f, 0.2f);
                    }
                    matchEndOverlay.style.display = DisplayStyle.Flex;
                    break;

                case MatchPhase.Defeat:
                    if (matchEndTitle != null)
                    {
                        matchEndTitle.text = "Defeat";
                        matchEndTitle.style.color = new Color(0.95f, 0.25f, 0.25f);
                    }
                    matchEndOverlay.style.display = DisplayStyle.Flex;
                    break;

                default:
                    matchEndOverlay.style.display = DisplayStyle.None;
                    break;
            }
        }

        // Reloads the active scene and restarts the host. Host-only — testing
        // convenience until a real lobby/restart flow exists. The static
        // sceneLoaded callback survives the scene reload (HUDController dies),
        // re-arms StartHost once the fresh scene has finished loading, and
        // unsubscribes itself.
        // Retry: take everyone back to the Lobby scene via LobbyService. The
        // lobby preserves race picks, clears ready state. Anyone who clicked
        // Return to Main Menu instead has already disconnected — they don't
        // come along.
        private void OnRetryClicked()
        {
            var svc = LobbyService.Instance;
            if (svc != null)
            {
                svc.RequestReturnToLobbyRpc();
                return;
            }

            // Fallback: LobbyService isn't spawned (e.g. testing the gameplay
            // scene standalone without the lobby flow). Hard-reload the scene.
            Debug.LogWarning("[HUDController] LobbyService not found — falling back to scene reload.");
            var nm = NetworkManager.Singleton;
            if (nm != null && nm.IsServer && nm.SceneManager != null)
                nm.SceneManager.LoadScene(SceneManager.GetActiveScene().name, LoadSceneMode.Single);
        }

        // Return to Main Menu: disconnect only this player. SessionFlow's
        // OnClientDisconnect handler routes us back to MainMenu locally. Other
        // peers remain in the match (until the host quits, at which point
        // SessionFlow on each remaining client routes them out too).
        private void OnReturnToMainMenuClicked()
        {
            TD.Net.NetworkBootstrap.Disconnect();
        }

        // ----- Helpers ----------------------------------------------------

        private static T Require<T>(VisualElement root, string name) where T : VisualElement
        {
            var el = root.Q<T>(name);
            if (el == null)
                Debug.LogWarning($"[HUDController] Element not found: '{name}' ({typeof(T).Name}). " +
                                 $"Check that the name matches the UXML.");
            return el;
        }

        private static void SetPickIgnore(VisualElement root, string name)
        {
            var el = root.Q<VisualElement>(name);
            if (el != null) el.pickingMode = PickingMode.Ignore;
        }
    }
}
