// Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Levels;
using TD.Towers;

namespace TD.Gameplay
{
    /// <summary>
    /// Server-authoritative manager for tower placement requests. Receives placement
    /// requests from clients via RPC, validates them in order, and either enqueues
    /// the placement on the player's <see cref="Builder"/> or rejects the request
    /// with a reason code.
    /// </summary>
    /// <remarks>
    /// <para><b>Queue-based RPC processing.</b> Incoming RPCs enqueue a
    /// <see cref="PlacementRequest"/> rather than validating inline. Each server
    /// Update drains up to <see cref="requestsPerFrame"/> requests. At 60 fps this
    /// gives ~180 validations/second, comfortably above the worst-case 90/second
    /// (9 players × 10 placements/second). Queuing keeps the server frame budget
    /// predictable regardless of burst traffic.</para>
    ///
    /// <para><b>Server-only logic.</b> All validation and mutation runs on the server.
    /// Clients learn about accepted placements when the build-site visual NetworkObject
    /// spawns (queued ghost) and later when the <see cref="TowerInstance"/> spawns at
    /// construction-complete. Clients learn about rejections via
    /// <see cref="PlacementRejectedRpc"/>.</para>
    ///
    /// <para><b>Validation order:</b>
    /// <list type="number">
    ///   <item>Tower type — must resolve to a valid TowerDefinition.</item>
    ///   <item>Ownership — every footprint tile must be owned by the requesting player.</item>
    ///   <item>Placement state + occupancy — every tile must be Buildable and not already occupied.
    ///         Occupancy includes both completed towers AND queued/constructing jobs in any
    ///         builder's queue.</item>
    ///   <item>Builder existence — the placing player must have a spawned builder. Build range
    ///         is NOT checked at queue-time — the queue is precisely the mechanism for deferring
    ///         "go there and build it." Range is enforced at construction-start in Builder.</item>
    ///   <item>Gold — the placing player must have enough gold.</item>
    ///   <item>Queue capacity — the placing player's builder queue must have room.</item>
    ///   <item>Path — a BFS confirms every spawner in the placing player's zone still reaches
    ///         an exit after the footprint is stamped as non-walkable. Note that QUEUED towers
    ///         do not participate in this BFS — only completed/constructing towers — because
    ///         queued ghosts are intent, not structures.</item>
    /// </list></para>
    ///
    /// <para><b>D2 build-queue flow.</b> On success, ProcessRequest does NOT spawn the tower.
    /// Instead it deducts gold, stamps occupancy=true (walkable stays true), and appends a
    /// BuildJob to the player's builder. The Builder owns walking to the site, transitioning
    /// to Constructing (which re-validates the path and stamps walkable=false), running the
    /// staged construction animation, and finally calling
    /// <see cref="ServerSpawnCompletedTower"/> to spawn the real TowerInstance.</para>
    /// </remarks>
    public class TowerPlacementManager : NetworkBehaviour
    {
        // ----- Singleton --------------------------------------------------

        /// <summary>
        /// The active TowerPlacementManager. Null before the scene loads or after it unloads.
        /// Always null-check before use. Only meaningful on the server; clients route all
        /// placement through RPC.
        /// </summary>
        public static TowerPlacementManager Instance { get; private set; }

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

        [Tooltip("Maximum number of placement requests processed per server Update tick. " +
                 "At 60 fps, 3 requests/frame = 180 validations/second, well above the " +
                 "worst-case 90/second (9 players × 10 placements/second).")]
        [SerializeField] private int requestsPerFrame = 3;

        [Tooltip("Tower CATALOG for this match, indexed by TowerTypeId. This is the full " +
                 "pool of towers that can exist (be drafted) this match — NOT what any one " +
                 "player can build. Per-player buildable sets live in PlayerTowerDeck. " +
                 "Index 0 is reserved; valid IDs start at 1.")]
        [SerializeField] private TowerDefinition[] towerDefinitions = new TowerDefinition[0];

        [Tooltip("The starter towers every player begins the match with. Each must also " +
                 "appear in the catalog above (that's where its TowerTypeId comes from). " +
                 "Resolved to TypeIds and granted to each PlayerTowerDeck at match start.")]
        [SerializeField] private TowerDefinition[] startingDeck = new TowerDefinition[0];

        // ----- Internal request queue -------------------------------------

        private struct PlacementRequest
        {
            public ulong SenderClientId;
            public Vector2Int Anchor;   // SW corner of the footprint, in world-tile coords.
            public int TowerTypeId;     // Index into towerDefinitions[].
        }

        private readonly Queue<PlacementRequest> pendingRequests = new Queue<PlacementRequest>();

        // Reusable scratch collections for BFS — allocated once and cleared per
        // check to avoid per-frame GC pressure. Only used on the server.
        private readonly Queue<Vector2Int> bfsQueue = new Queue<Vector2Int>();
        private readonly HashSet<Vector2Int> bfsVisited = new HashSet<Vector2Int>();

        // Scratch set for "tiles that should be treated as blocked for this BFS run only"
        // — populated by queue-time path-validity checks with the candidate tower's footprint
        // tiles. Avoids the stamp-then-restore pattern (which fired walkability-change events
        // on tiles whose net state didn't change, causing a cascade of enemy re-paths).
        private readonly HashSet<Vector2Int> virtualBlockedScratch = new HashSet<Vector2Int>();

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

        public override void OnNetworkSpawn()
        {
            if (Instance != null && Instance != this)
            {
                Debug.LogError("[TowerPlacementManager] Multiple instances detected. " +
                               "Only one TowerPlacementManager should exist per scene.");
            }
            Instance = this;

            if (IsServer)
                Debug.Log("[TowerPlacementManager] Server ready.");
        }

        public override void OnNetworkDespawn()
        {
            if (Instance == this) Instance = null;
        }

        // ----- Server Update — queue drain --------------------------------

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

            int processed = 0;
            while (pendingRequests.Count > 0 && processed < requestsPerFrame)
            {
                ProcessRequest(pendingRequests.Dequeue());
                processed++;
            }
        }

        // ----- RPC: client → server (placement request) -------------------

        /// <summary>
        /// Client entry point. Call this on the local client to request placing a tower.
        /// The server will validate and either enqueue the placement (which spawns a
        /// build-site visual visible to all clients) or call back with
        /// <see cref="PlacementRejectedRpc"/>.
        /// </summary>
        [Rpc(SendTo.Server)]
        public void RequestPlaceTowerRpc(int anchorX, int anchorY, int towerTypeId,
                                         RpcParams rpcParams = default)
        {
            pendingRequests.Enqueue(new PlacementRequest
            {
                SenderClientId = rpcParams.Receive.SenderClientId,
                Anchor = new Vector2Int(anchorX, anchorY),
                TowerTypeId = towerTypeId,
            });
        }

        // ----- RPC: server → client (rejection notification) --------------

        /// <summary>
        /// Sent by the server to the placing client when a placement request is rejected.
        /// The client uses the reason to display a feedback message to the player.
        /// </summary>
        [Rpc(SendTo.SpecifiedInParams)]
        private void PlacementRejectedRpc(PlacementRejectionReason reason,
                                          RpcParams rpcParams = default)
        {
            // This executes on the target client.
            Debug.Log($"[TowerPlacementManager] Placement rejected: {reason}");

            // TowerPlacementController listens for this via the static event below.
            OnPlacementRejected?.Invoke(reason);
        }

        /// <summary>
        /// Fired on the local client when the server rejects this client's placement request.
        /// <see cref="TowerPlacementController"/> subscribes to this to display rejection
        /// feedback messages.
        /// </summary>
        public static event System.Action<PlacementRejectionReason> OnPlacementRejected;

        // ----- Core validation and placement logic ------------------------

        private void ProcessRequest(PlacementRequest req)
        {
            // Resolve the TowerDefinition first — needed by every subsequent check.
            if (!TryGetDefinition(req.TowerTypeId, out TowerDefinition def))
            {
                Reject(req, PlacementRejectionReason.InvalidTowerType);
                return;
            }

            // Deck membership — the requesting player must have unlocked this tower.
            // Authoritative gate: a modded client cannot place a tower it hasn't drafted,
            // even though every tower exists in the shared catalog.
            var deck = PlayerTowerDeck.GetForClient(req.SenderClientId);
            if (deck == null || !deck.Contains(req.TowerTypeId))
            {
                Reject(req, PlacementRejectionReason.TowerNotInDeck);
                return;
            }

            var loader = LevelLoader.Instance;
            if (loader == null || !loader.IsLoaded)
            {
                Reject(req, PlacementRejectionReason.ServerError);
                return;
            }

            // Collect the footprint tiles once; used by all subsequent checks.
            var footprint = new List<Vector2Int>(def.FootprintSize.x * def.FootprintSize.y);
            foreach (var tile in GridCoordinates.GetFootprintTiles(req.Anchor, def.FootprintSize))
                footprint.Add(tile);

            // ------------------------------------------------------------------
            // Check 1: Ownership
            // Every footprint tile must be owned by the placing player's zone.
            // ------------------------------------------------------------------
            PlayerSlot placingSlot = ClientIdToPlayerSlot(req.SenderClientId);
            if (placingSlot == PlayerSlot.None)
            {
                Reject(req, PlacementRejectionReason.ServerError);
                return;
            }

            foreach (var tile in footprint)
            {
                if (loader.GetOwner(tile) != placingSlot)
                {
                    Reject(req, PlacementRejectionReason.WrongOwner);
                    return;
                }
            }

            // ------------------------------------------------------------------
            // Check 2: Placement state + occupancy
            // Every footprint tile must be Buildable and not already occupied. Note
            // that the occupancy grid was stamped at queue-time for ALL pending jobs
            // in any builder's queue, so a single IsOccupied check correctly rejects
            // overlap with a queued ghost (in this builder's queue OR another's).
            // ------------------------------------------------------------------
            foreach (var tile in footprint)
            {
                if (loader.GetPlacement(tile) != PlacementState.Buildable)
                {
                    Reject(req, PlacementRejectionReason.TileNotBuildable);
                    return;
                }
                if (loader.IsOccupied(tile))
                {
                    Reject(req, PlacementRejectionReason.TileOccupied);
                    return;
                }
            }

            // ------------------------------------------------------------------
            // Check 3: Builder must exist
            // The placing player needs a spawned builder to take ownership of the
            // resulting BuildJob, but build range is NOT checked here. The whole
            // point of a queue is to defer "go there and build it" — if the builder
            // is out of range at queue-time, it will walk to the site when the job
            // reaches the head of the queue. Range is enforced at construction-start
            // (in Builder.DriveHead_Queued) which is the moment range actually matters.
            // ------------------------------------------------------------------
            var builder = Builder.GetForClient(req.SenderClientId);
            if (builder == null)
            {
                // No builder spawned for this client — server-side error, not a player-facing one.
                Reject(req, PlacementRejectionReason.ServerError);
                return;
            }

            // ------------------------------------------------------------------
            // Check 4: Gold
            // Validate before the path check — cheaper, and no point running BFS
            // if the player can't afford the tower.
            // ------------------------------------------------------------------
            var goldManager = PlayerGoldManager.GetForClient(req.SenderClientId);
            if (goldManager == null || goldManager.CurrentGold < def.GoldCost)
            {
                Reject(req, PlacementRejectionReason.InsufficientGold);
                return;
            }

            // ------------------------------------------------------------------
            // Check 5: Queue capacity
            // The placing player's builder must have room for one more job.
            // Cheap check; runs before the path BFS.
            // ------------------------------------------------------------------
            if (builder.Jobs.Count >= builder.MaxQueueDepth)
            {
                Reject(req, PlacementRejectionReason.JobLimitReached);
                return;
            }

            // ------------------------------------------------------------------
            // Check 6: Path validity (queue-time)
            // Virtually treat the footprint as non-walkable and run BFS per spawner
            // in the placing player's zone. We do NOT modify the grid here — the
            // BFS just consults a "virtually blocked" tile set in addition to
            // IsWalkable. Importantly we do NOT block other queued (but not yet
            // constructing) jobs — queued ghosts represent intent only and don't
            // block enemies. The check is "could THIS tower be built right now if
            // it were instantly complete?" — a coarse test that catches obvious
            // blockers at queue-time. The construction-start re-check (in
            // Builder.DriveHead_Queued) catches cases where the maze changed since
            // queue-time.
            //
            // Why virtual instead of stamp-and-restore: every real walkability
            // flip fires OnWalkabilityChanged which triggers all enemies to A*.
            // Stamp-and-restore (no net change) would fire those events twice for
            // no reason. The virtual approach has zero side-effects.
            // ------------------------------------------------------------------
            virtualBlockedScratch.Clear();
            foreach (var tile in footprint) virtualBlockedScratch.Add(tile);

            bool pathValid = CheckPathValidity(loader, placingSlot, virtualBlockedScratch);

            if (!pathValid)
            {
                Reject(req, PlacementRejectionReason.BlocksPath);
                return;
            }

            // ------------------------------------------------------------------
            // All checks passed — commit the queue entry.
            //   - Mark the footprint occupied (but keep it walkable; queued ghosts
            //     don't block enemies).
            //   - Deduct gold.
            //   - Append the BuildJob to the builder's queue. The Builder spawns
            //     the green-ghost build-site visual itself.
            // ------------------------------------------------------------------
            StampOccupied(loader, footprint, occupied: true);
            goldManager.DeductGold(def.GoldCost);

            if (!builder.ServerEnqueueJob(req.Anchor, req.TowerTypeId, def.GoldCost,
                                          out ulong jobId))
            {
                // Should not happen — we checked capacity above. Defensive: roll back.
                StampOccupied(loader, footprint, occupied: false);
                goldManager.AwardGold(def.GoldCost);
                Reject(req, PlacementRejectionReason.JobLimitReached);
                return;
            }

            Debug.Log($"[TowerPlacementManager] Queued '{def.DisplayName}' (job {jobId}) for " +
                      $"client {req.SenderClientId} ({placingSlot}) at anchor {req.Anchor}.");
        }

        // ----- Server-side commit hooks called by Builder ------------------

        /// <summary>
        /// Server-only: called by <c>Builder</c> the moment a queued job's builder arrives
        /// at the build site. Stamps the footprint non-walkable and re-runs the path BFS.
        /// On success, the footprint stays non-walkable and the caller transitions the
        /// job to Constructing. On failure, walkability is restored and false is returned;
        /// caller must drop and refund the job.
        /// </summary>
        public bool ServerCommitConstructionStart(Vector2Int anchor, Vector2Int footprintSize,
                                                  PlayerSlot placingSlot)
        {
            if (!IsServer) return false;

            var loader = LevelLoader.Instance;
            if (loader == null || !loader.IsLoaded) return false;

            var footprint = new List<Vector2Int>(footprintSize.x * footprintSize.y);
            foreach (var tile in GridCoordinates.GetFootprintTiles(anchor, footprintSize))
                footprint.Add(tile);

            // Virtual check first — no grid mutation, no walkability events fire while
            // we're just asking "would this break the maze?". Only if the check passes
            // do we stamp the footprint for real, which fires exactly one batched event.
            virtualBlockedScratch.Clear();
            foreach (var tile in footprint) virtualBlockedScratch.Add(tile);

            if (!CheckPathValidity(loader, placingSlot, virtualBlockedScratch))
            {
                // Maze would break. Caller refunds and drops the job. Grid untouched,
                // no events fired.
                return false;
            }

            // Commit: stamp the footprint non-walkable. Single batched event fires
            // OnWalkabilityChanged once for the whole footprint, regardless of size.
            StampWalkable(loader, footprint, walkable: false);
            return true;
        }

        /// <summary>
        /// Server-only: read-only path validity check for the given player's zone.
        /// Used by <c>Builder</c> when resuming a paused job — the footprint is
        /// already non-walkable (has been since original construction-start), so
        /// stamping/un-stamping would be a no-op at best and a bug at worst (an
        /// un-stamp would un-block an actively-blocking tile). This method just
        /// runs the BFS against the current grid state and returns the result.
        /// </summary>
        public bool ServerVerifyPathStillValid(PlayerSlot placingSlot)
        {
            if (!IsServer) return false;

            var loader = LevelLoader.Instance;
            if (loader == null || !loader.IsLoaded) return false;

            return CheckPathValidity(loader, placingSlot);
        }

        /// <summary>
        /// Server-only: called by <c>Builder</c> when a constructing job completes.
        /// Spawns the real <see cref="TowerInstance"/> NetworkObject at the anchor.
        /// The footprint is already occupied and non-walkable from the construction-start
        /// commit; <c>TowerInstance.OnNetworkSpawn</c>'s footprint stamp is idempotent
        /// and harmless.
        /// </summary>
        public void ServerSpawnCompletedTower(TowerDefinition def, Vector2Int anchor,
                                              PlayerSlot owner)
        {
            if (!IsServer) return;
            SpawnTower(def, anchor, owner);
        }

        // ----- Path-validity BFS ------------------------------------------

        /// <summary>
        /// Returns true if every spawner in <paramref name="placingSlot"/>'s zone can still
        /// reach an exit tile (any leak exit tile OR any goal tile) via walkable tiles,
        /// given the current state of <c>LevelLoader</c>'s runtime walkability grid.
        /// </summary>
        /// <remarks>
        /// Mirrors bake-time P5-4. Runs a BFS per spawner against the runtime walkability
        /// grid. Reuses <see cref="bfsQueue"/> and <see cref="bfsVisited"/> scratch
        /// collections (cleared between BFS runs) to avoid GC allocation per call.
        /// </remarks>
        private bool CheckPathValidity(LevelLoader loader, PlayerSlot slot,
                                       HashSet<Vector2Int> virtualBlocked = null)
        {
            var levelData = loader.LevelData;

            // Find the PlayerZoneData for this slot.
            PlayerZoneData zoneData = null;
            foreach (var zone in levelData.PlayerZones)
            {
                if (zone.Owner == slot) { zoneData = zone; break; }
            }
            if (zoneData == null || zoneData.Spawners == null || zoneData.Spawners.Length == 0)
            {
                // No spawners means nothing to block — treat as valid.
                return true;
            }

            // Build the exit tile set: union of all leak exit tiles and all goal tiles.
            var exitTiles = BuildExitTileSet(levelData, slot);
            if (exitTiles.Count == 0)
            {
                Debug.LogWarning($"[TowerPlacementManager] Zone {slot} has no exit tiles. " +
                                 $"This should have been caught at bake time (P5-8).");
                return true;
            }

            // BFS per spawner: each spawner's tile area is the BFS seed set. The optional
            // virtualBlocked set lets queue-time checks treat the candidate footprint as
            // non-walkable WITHOUT modifying the grid (avoiding spurious walkability events).
            foreach (var spawner in zoneData.Spawners)
            {
                if (!SpawnerCanReachExit(loader, spawner, exitTiles, virtualBlocked))
                    return false;
            }

            return true;
        }

        /// <summary>
        /// Builds the set of tiles that count as "exits" for the given player zone:
        /// all tiles of every LeakExit FROM this zone, plus all goal tiles.
        /// </summary>
        private HashSet<Vector2Int> BuildExitTileSet(LevelData levelData, PlayerSlot slot)
        {
            var exits = new HashSet<Vector2Int>();

            // Leak exit tiles for this zone.
            foreach (var zone in levelData.PlayerZones)
            {
                if (zone.Owner != slot) continue;
                if (zone.LeakExits == null) continue;
                foreach (var leak in zone.LeakExits)
                    foreach (var tile in leak.TileArea)
                        exits.Add(tile);
            }

            // Goal tiles (all goals count as exits for the final defender zone).
            if (levelData.Goals != null)
                foreach (var goal in levelData.Goals)
                    foreach (var tile in goal.TileArea)
                        exits.Add(tile);

            return exits;
        }

        /// <summary>
        /// BFS from <paramref name="spawner"/>'s tile area. Returns true if any exit tile
        /// is reachable via walkable tiles. Uses the shared scratch queue and visited set.
        /// </summary>
        private bool SpawnerCanReachExit(LevelLoader loader, SpawnerData spawner,
                                          HashSet<Vector2Int> exitTiles,
                                          HashSet<Vector2Int> virtualBlocked = null)
        {
            bfsQueue.Clear();
            bfsVisited.Clear();

            // Local walkability check that honors the virtual-blocked override. Hot-path
            // helper so we don't duplicate the conditional inside every neighbor test.
            bool IsTileOpen(Vector2Int t)
            {
                if (virtualBlocked != null && virtualBlocked.Contains(t)) return false;
                return loader.IsWalkable(t);
            }

            // Seed the BFS with the spawner's full tile area (not just its center tile),
            // matching bake-time P5-4 exactly.
            foreach (var tile in spawner.TileArea)
            {
                if (bfsVisited.Add(tile))
                    bfsQueue.Enqueue(tile);
            }

            while (bfsQueue.Count > 0)
            {
                var current = bfsQueue.Dequeue();

                if (exitTiles.Contains(current))
                    return true;

                // 8-connected expansion to match enemy pathfinding. A 4-connected
                // BFS here would reject placements enemies could actually navigate
                // around via diagonals, OR accept placements that diagonally squeeze
                // through corners. Corner-cut prevention keeps the maze rule consistent
                // with PathfindingService: a diagonal step requires both shoulder
                // cardinal tiles to be walkable.
                foreach (var neighbor in GridCoordinates.GetNeighbors8(current))
                {
                    if (bfsVisited.Contains(neighbor)) continue;
                    if (!IsTileOpen(neighbor)) continue;

                    if (GridCoordinates.IsDiagonal(current, neighbor))
                    {
                        GridCoordinates.GetCornerShoulders(current, neighbor,
                            out var shoulderA, out var shoulderB);
                        if (!IsTileOpen(shoulderA) || !IsTileOpen(shoulderB))
                            continue;
                    }

                    bfsVisited.Add(neighbor);
                    bfsQueue.Enqueue(neighbor);
                }
            }

            return false;
        }

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

        /// <summary>
        /// Stamps walkability on every tile in <paramref name="footprint"/>.
        /// Independent of occupancy because the queue-time and construction-time
        /// transitions touch them on different schedules.
        /// </summary>
        private static void StampWalkable(LevelLoader loader, List<Vector2Int> footprint,
                                          bool walkable)
        {
            // Batched: fires OnWalkabilityChanged at most once for the whole footprint,
            // instead of once per tile. Without this, a 2×2 placement fires 4 enemy
            // re-paths instead of 1; a 3×3 fires 9. The cascade was the dominant
            // contributor to placement stutter on larger maps.
            loader.SetWalkableBatch(footprint, walkable);
        }

        /// <summary>
        /// Stamps occupancy on every tile in <paramref name="footprint"/>.
        /// </summary>
        private static void StampOccupied(LevelLoader loader, List<Vector2Int> footprint,
                                          bool occupied)
        {
            foreach (var tile in footprint)
                loader.SetOccupied(tile, occupied);
        }

        /// <summary>
        /// Spawns the tower NetworkObject at the footprint center and records the
        /// placing player's slot on the <see cref="TowerInstance"/> component.
        /// </summary>
        private void SpawnTower(TowerDefinition def, Vector2Int anchor, PlayerSlot owner)
        {
            if (def.TowerPrefab == null)
            {
                Debug.LogError($"[TowerPlacementManager] TowerDefinition '{def.DisplayName}' " +
                               $"has no TowerPrefab assigned. Cannot spawn.");
                return;
            }

            Vector3 spawnPos = GridCoordinates.GetFootprintCenterWorld(anchor, def.FootprintSize);
            // Place on the buildable plane (Y=0); SeatOnGround below corrects the final Y
            // from the mesh bounds so the tower rests flush regardless of its pivot.
            spawnPos.y = 0f;

            var go = Instantiate(def.TowerPrefab, spawnPos, Quaternion.identity);
            var instance = go.GetComponent<TowerInstance>();
            if (instance == null)
            {
                Debug.LogError($"[TowerPlacementManager] TowerPrefab '{def.TowerPrefab.name}' " +
                               $"is missing a TowerInstance component.");
                Destroy(go);
                return;
            }

            // Set data before network spawn so TowerInstance.OnNetworkSpawn sees it.
            instance.InitializeServer(def, anchor, owner);

            var netObj = go.GetComponent<NetworkObject>();
            if (netObj == null)
            {
                Debug.LogError($"[TowerPlacementManager] TowerPrefab '{def.TowerPrefab.name}' " +
                               $"is missing a NetworkObject component.");
                Destroy(go);
                return;
            }

            // Seat the tower flush on the ground from its mesh bounds — BEFORE Spawn so the
            // corrected transform is captured in the spawn sync. Handles any pivot.
            SeatOnGround(go, groundY: 0f);

            netObj.Spawn(destroyWithScene: true);
        }

        /// <summary>
        /// Shifts <paramref name="go"/> vertically so the lowest point of its combined
        /// renderer bounds rests at <paramref name="groundY"/>. Lets towers be authored with
        /// any mesh pivot (center, base, etc.) and still sit flush on the ground — no
        /// per-prefab Y tweaking. No-op if the prefab has no renderers.
        /// </summary>
        private static void SeatOnGround(GameObject go, float groundY)
        {
            var renderers = go.GetComponentsInChildren<Renderer>();
            if (renderers.Length == 0) return;

            Bounds b = renderers[0].bounds;
            for (int i = 1; i < renderers.Length; i++)
                b.Encapsulate(renderers[i].bounds);

            go.transform.position += Vector3.up * (groundY - b.min.y);
        }

        // ----- Utility ----------------------------------------------------

        private static bool TryGetDefinition(int typeId, out TowerDefinition def)
        {
            def = null;
            // typeId 0 is reserved; valid IDs start at 1.
            if (typeId <= 0) return false;
            if (Instance == null) return false;
            if (typeId >= Instance.towerDefinitions.Length) return false;
            def = Instance.towerDefinitions[typeId];
            return def != null;
        }

        /// <summary>
        /// Public lookup used by <see cref="Builder"/> to resolve a tower type ID
        /// to its definition. Returns null if the ID is invalid.
        /// </summary>
        public static TowerDefinition GetDefinition(int typeId)
        {
            return TryGetDefinition(typeId, out var def) ? def : null;
        }

        /// <summary>
        /// Enumerates all valid (definition, typeId) pairs from the current definition list.
        /// TypeId 0 is reserved; valid entries start at index 1. Used by the HUD command grid
        /// to populate tower buttons. Only meaningful on the instance — check Instance != null
        /// before calling.
        /// </summary>
        public System.Collections.Generic.IEnumerable<(TowerDefinition def, int typeId)>
            GetAvailableDefinitions()
        {
            for (int i = 1; i < towerDefinitions.Length; i++)
            {
                if (towerDefinitions[i] != null)
                    yield return (towerDefinitions[i], i);
            }
        }

        /// <summary>
        /// Reverse lookup: returns the TowerTypeId (catalog index) for a given
        /// definition asset, or false if it isn't in the catalog. Used to resolve the
        /// <see cref="startingDeck"/> assets into the TypeIds a <see cref="PlayerTowerDeck"/>
        /// stores.
        /// </summary>
        public bool TryGetTypeId(TowerDefinition def, out int typeId)
        {
            typeId = 0;
            if (def == null) return false;
            for (int i = 1; i < towerDefinitions.Length; i++)
            {
                if (towerDefinitions[i] == def) { typeId = i; return true; }
            }
            return false;
        }

        /// <summary>
        /// Resolves the configured <see cref="startingDeck"/> assets to their catalog
        /// TypeIds. Logs an error for any starter tower missing from the catalog (it
        /// would have no TypeId and couldn't be placed). Called once at match start.
        /// </summary>
        public List<int> GetStartingDeckTypeIds()
        {
            var ids = new List<int>();
            if (startingDeck == null) return ids;

            foreach (var def in startingDeck)
            {
                if (def == null) continue;
                if (TryGetTypeId(def, out int id))
                    ids.Add(id);
                else
                    Debug.LogError($"[TowerPlacementManager] Starting-deck tower '{def.name}' " +
                                   $"is not in the towerDefinitions catalog. Add it to the catalog " +
                                   $"so it has a TowerTypeId, or it can't be granted/placed.");
            }
            return ids;
        }

        private static PlayerSlot ClientIdToPlayerSlot(ulong clientId)
            => PlayerMatchState.SlotForClient(clientId);

        private void Reject(PlacementRequest req, PlacementRejectionReason reason)
        {
            Debug.Log($"[TowerPlacementManager] Rejected request from client " +
                      $"{req.SenderClientId} at anchor {req.Anchor}: {reason}");

            // Send the rejection RPC back to only the requesting client.
            PlacementRejectedRpc(reason,
                new RpcParams
                {
                    Send = new RpcSendParams
                    {
                        Target = RpcTarget.Single(req.SenderClientId, RpcTargetUse.Temp)
                    }
                });
        }
    }

    /// <summary>
    /// Reason codes sent to the client when the server rejects a placement request.
    /// Used by <see cref="TowerPlacementController"/> to display the appropriate
    /// feedback message to the player.
    /// </summary>
    public enum PlacementRejectionReason
    {
        /// <summary>One or more footprint tiles belong to a different player's zone.</summary>
        WrongOwner,

        /// <summary>One or more footprint tiles are not in a Buildable state
        /// (they are Restricted or Outside the map).</summary>
        TileNotBuildable,

        /// <summary>One or more footprint tiles are already occupied by an existing tower
        /// or by a queued/constructing build job.</summary>
        TileOccupied,

        /// <summary>The placing player does not have enough gold.</summary>
        InsufficientGold,

        /// <summary>The placing player's builder is too far from the requested location.</summary>
        OutOfRange,

        /// <summary>Placing this tower would block all valid paths from at least one
        /// spawner to its exit. The maze must remain passable.</summary>
        BlocksPath,

        /// <summary>The placing player's builder queue is at capacity. Cancel pending
        /// jobs or wait for one to complete before queuing more.</summary>
        JobLimitReached,

        /// <summary>The requested tower type ID is not in the server's definition list.</summary>
        InvalidTowerType,

        /// <summary>The requested tower exists in the catalog but the placing player has
        /// not unlocked it in their deck. Draft it first.</summary>
        TowerNotInDeck,

        /// <summary>An unexpected server-side error occurred (e.g., LevelLoader not loaded,
        /// client not mapped to a PlayerSlot). Check server logs.</summary>
        ServerError,
    }
}