// Assets/_Project/Scripts/Gameplay/Builder.cs
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Towers;
using TD.UI.Minimap;

namespace TD.Gameplay
{
    /// <summary>
    /// Per-player avatar that gates tower placement by proximity AND drives the build
    /// queue. Server-authoritative position and queue; clients submit move requests
    /// via Rpc and read replicated NetworkList state to render queued/constructing
    /// visuals.
    /// </summary>
    /// <remarks>
    /// <para><b>Pure visual avatar.</b> Builders have no collider for gameplay purposes
    /// (no enemy blocking, not targetable). They DO have a small trigger collider on
    /// the "Selection" physics layer so left-click can select them — that collider
    /// must not be on layers that participate in placement raycasts or builder
    /// height sampling. See the prefab setup notes in the project context doc.</para>
    ///
    /// <para><b>Terrain-aware height.</b> Each frame the server casts a ray straight
    /// down from the builder against <see cref="terrainLayerMask"/> and sets Y to
    /// <c>hit.point.y + heightOffset</c>. Falls back to <see cref="GridCoordinates.BUILDABLE_PLANE_Y"/>
    /// if the ray misses. Towers must not be on the terrain layer.</para>
    ///
    /// <para><b>Range gating.</b> <see cref="IsTileWithinBuildRange"/> is the public
    /// query that <c>TowerPlacementManager</c> uses to validate placement requests.
    /// Range is measured center-of-builder to nearest-point-of-footprint in world units.</para>
    ///
    /// <para><b>Build queue.</b> Each Builder owns a <see cref="NetworkList{T}"/> of
    /// <see cref="BuildJob"/>. The server appends jobs from
    /// <c>TowerPlacementManager.ProcessRequest</c> via <see cref="ServerEnqueueJob"/>.
    /// Each server tick, if the head job is Queued, the builder walks toward its
    /// anchor; on arrival, the head job transitions to Constructing (path re-validated
    /// here; refunded and dropped if the maze would now break). Stages advance by
    /// time; on completion the visual is replaced with a real <c>TowerInstance</c>
    /// and the head job is removed. Cancellation drops every job in the queue and
    /// refunds 100% per the design doc.</para>
    ///
    /// <para><b>Static registry.</b> Like <see cref="PlayerGoldManager"/>, builders
    /// register themselves in a static dictionary keyed by <c>OwnerClientId</c> on
    /// spawn so server gameplay code can find a player's builder without scene
    /// traversal.</para>
    /// </remarks>
    [RequireComponent(typeof(NetworkObject))]
    public class Builder : NetworkBehaviour, IMinimapEntity, ISelectable
    {
        // ----- Static registry --------------------------------------------

        private static readonly Dictionary<ulong, Builder> s_byClientId
            = new Dictionary<ulong, Builder>();

        /// <summary>
        /// Returns the Builder owned by the given client, or null if none is currently spawned.
        /// Safe to call on server or client.
        /// </summary>
        public static Builder GetForClient(ulong clientId)
        {
            s_byClientId.TryGetValue(clientId, out var builder);
            return builder;
        }

        /// <summary>
        /// Convenience: the local client's own builder. Returns null on a dedicated server
        /// or before the local player has spawned.
        /// </summary>
        public static Builder Local
        {
            get
            {
                var nm = NetworkManager.Singleton;
                if (nm == null || !nm.IsClient) return null;
                return GetForClient(nm.LocalClientId);
            }
        }

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

        [Header("Settings")]
        [Tooltip("Shared tunable values for all builders. Create via TD/Builder Settings.")]
        [SerializeField] private BuilderSettings settings;

        [Header("Build queue")]
        [Tooltip("Build-site visual prefab. Spawned at queue-time as a green ghost; " +
                 "transitions to staged-construction visuals on arrival; despawned on " +
                 "completion (replaced by the real TowerInstance) or cancellation.")]
        [SerializeField] private GameObject buildSiteVisualPrefab;

        [Header("Visuals")]
        [Tooltip("Mesh renderers that should be tinted with the owner's player color. " +
                 "Drag in only the builder body's renderers — exclude the SelectionRing, " +
                 "BuildRangeIndicator, or any other visual that has its own color rules. " +
                 "If left empty, the builder will not be tinted (other meshes' colors " +
                 "from the prefab are preserved).")]
        [SerializeField] private SkinnedMeshRenderer[] tintedRenderers;

        [Header("Animation")]
        [Tooltip("Animator on the character model child. Drives IsMoving and IsConstructing " +
                 "bool parameters each frame on all clients.")]
        [SerializeField] private Animator animator;

        // ----- Networked state --------------------------------------------

        // Server-authoritative target position. The server moves the builder toward this
        // each frame; clients render the builder smoothly via NetworkTransform's interpolation.
        // We replicate the target (not the live position) so the server's intent is visible
        // to clients, but the rendered position is whatever NetworkTransform interpolates to.
        private readonly NetworkVariable<Vector3> targetPosition = new NetworkVariable<Vector3>(
            value: Vector3.zero,
            readPerm: NetworkVariableReadPermission.Everyone,
            writePerm: NetworkVariableWritePermission.Server);

        // The build queue. Replicated as a NetworkList so all clients can render queued
        // ghosts and progress without per-job RPCs. Server is the only writer.
        private NetworkList<BuildJob> jobs;

        /// <summary>
        /// Read-only access to the current job queue. Clients use this to render
        /// build-site visuals at queued anchors. Index 0 is the head (active) job.
        /// </summary>
        public NetworkList<BuildJob> Jobs => jobs;

        // ----- Server-only state ------------------------------------------
        //
        // Tracks the BuildSiteVisual NetworkObject for each active job so we can
        // despawn it on completion or cancellation. NetworkList itself doesn't
        // hold object references; we keep the side-table here.
        private readonly Dictionary<ulong, NetworkObject> jobIdToVisual
            = new Dictionary<ulong, NetworkObject>();

        // Monotonic job-ID counter on the server. Resets per builder, which is fine —
        // IDs only need to be unique within one builder's queue.
        private ulong nextJobId = 1;

        // ----- Public accessors -------------------------------------------

        /// <summary>The builder's current world position (its actual transform position,
        /// not the target).</summary>
        public Vector3 CurrentPosition => transform.position;

        /// <summary>The builder's target position. Server moves toward this each frame.</summary>
        public Vector3 TargetPosition => targetPosition.Value;

        /// <summary>True if the builder has arrived at its target (within
        /// <see cref="BuilderSettings.arrivalThreshold"/>).</summary>
        public bool IsAtTarget =>
            Vector3.SqrMagnitude(transform.position - targetPosition.Value)
            < settings.arrivalThreshold * settings.arrivalThreshold;

        /// <summary>Build range in world units.</summary>
        public float BuildRange => settings.buildRange;

        /// <summary>Maximum jobs allowed in the queue.</summary>
        public int MaxQueueDepth => settings.maxQueueDepth;

        // ----- ISelectable ------------------------------------------------

        /// <summary>Display name shown in the HUD portrait. Stub until MatchState provides player names.</summary>
        public string DisplayName
        {
            get
            {
                PlayerSlot slot = OwnerToSlot(OwnerClientId);
                int n = (int)slot;
                return n >= 1 && n <= 9 ? $"Builder (P{n})" : "Builder";
            }
        }

        public SelectableKind Kind => SelectableKind.Builder;

        public Transform SelectionTransform => transform;

        // Builders are point units; the visible silhouette is roughly 1 unit wide.
        // 0.6 puts a small visible gap between the silhouette and the ring.
        // Bump up if BuilderSettings later exposes a width or selection radius.
        public float SelectionRadius => 0.6f;

        /// <summary>True if a tile is currently part of any queued or constructing job.</summary>
        /// <remarks>
        /// Used by <c>TowerPlacementManager</c> to reject placement on tiles already
        /// covered by another pending job in this builder's queue. Walks every job
        /// and footprint — O(jobs × tiles) but bounded by <see cref="MaxQueueDepth"/>.
        /// </remarks>
        public bool IsTileInActiveJob(Vector2Int tile)
        {
            for (int i = 0; i < jobs.Count; i++)
            {
                var job = jobs[i];
                Vector2Int footprint = ResolveFootprint(job.TowerTypeId);
                foreach (var t in GridCoordinates.GetFootprintTiles(job.Anchor, footprint))
                {
                    if (t == tile) return true;
                }
            }
            return false;
        }

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

        private void Awake()
        {
            // NetworkList must be allocated before NetworkObject.Spawn() (NGO 2.x
            // discovers it during the spawn handshake). Awake runs before any
            // network lifecycle method, so this is the right place.
            jobs = new NetworkList<BuildJob>();
        }

        public override void OnNetworkSpawn()
        {
            s_byClientId[OwnerClientId] = this;
            MinimapEntityRegistry.Register(this);

            if (IsServer)
            {
                // Set initial target = current position so the builder doesn't drift on spawn.
                // The spawner is responsible for placing this builder at a sensible position
                // BEFORE Spawn() — see PlayerBuilderSpawner.
                targetPosition.Value = transform.position;
                Debug.Log($"[Builder] Spawned for client {OwnerClientId} at " +
                          $"{transform.position}.");
            }

            ApplyOwnerColor();

            // Auto-select on spawn for the local owner. RTS-standard "your unit is
            // selected by default when it appears" — without this, the player has to
            // click their own builder before any right-click command works, which is
            // friction. Players can still deselect (left-click empty space, Escape)
            // and re-select normally. Owner-gated so remote clients don't accidentally
            // get someone else's builder selected.
            if (IsOwner)
            {
                SelectionState.Instance?.Select(this);
            }
        }

        public override void OnNetworkDespawn()
        {
            if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this)
                s_byClientId.Remove(OwnerClientId);
            MinimapEntityRegistry.Deregister(this);

            // Clear local selection if THIS builder was selected. Without this,
            // SelectionState (and any subscriber holding our reference — HUD,
            // SelectionVisualizer) keeps pointing at a soon-to-be-destroyed Unity
            // object and throws MissingReferenceException on the next access.
            // Local-only state, so safe to touch from any peer.
            if (SelectionState.Instance != null && SelectionState.Instance.IsSelected(this))
                SelectionState.Instance.Clear();

            // Server-only cleanup: despawn any remaining build-site visuals so they
            // don't leak when a player disconnects mid-construction.
            if (IsServer)
            {
                foreach (var kv in jobIdToVisual)
                {
                    if (kv.Value != null && kv.Value.IsSpawned)
                        kv.Value.Despawn(destroy: true);
                }
                jobIdToVisual.Clear();
            }
        }

        // (NetworkList is owned by NGO; no manual Dispose needed in NGO 2.x.)

        // ----- IMinimapEntity ---------------------------------------------
        //
        // Position is read live (NetworkTransform interpolation handles remote-client smoothing).
        // Color comes from PlayerMatchState slot assignment.

        Vector3 IMinimapEntity.WorldPosition => transform.position;

        Color IMinimapEntity.MinimapColor
            => PlayerColors.Get(PlayerMatchState.SlotForClient(OwnerClientId));

        MinimapIconKind IMinimapEntity.IconKind => MinimapIconKind.Builder;

        // Diameter of the builder dot in world units. The view enforces a pixel-size floor so
        // builders remain visible at full zoom-out regardless of this value.
        float IMinimapEntity.MinimapWorldSize => 0.6f;

        // ----- Owner color tinting ----------------------------------------

        // Lazily allocated; reused across renderers. Construction in a field initializer
        // would throw on this MonoBehaviour at scene load.
        private MaterialPropertyBlock colorPropertyBlock;
        // Both _Color (legacy Standard) and _BaseColor (URP Lit) — writing both lets the
        // tint apply regardless of which shader the prefab uses. Unknown property writes
        // are silently ignored by the shader.
        private static readonly int ColorPropertyId = Shader.PropertyToID("_Color");
        private static readonly int BaseColorPropertyId = Shader.PropertyToID("_BaseColor");

        private void ApplyOwnerColor()
        {
            Color c = PlayerColors.Get(PlayerMatchState.SlotForClient(OwnerClientId));
            c.a = 1f;

            colorPropertyBlock ??= new MaterialPropertyBlock();
            colorPropertyBlock.SetColor(ColorPropertyId, c);
            colorPropertyBlock.SetColor(BaseColorPropertyId, c);

            // Tint only the renderers explicitly listed in the inspector. Avoids
            // accidentally re-coloring the SelectionRing or BuildRangeIndicator,
            // which need their own (non-player) colors. If the list is empty,
            // skip — the builder shows whatever colors are baked into the prefab.
            if (tintedRenderers == null) return;
            foreach (var rend in tintedRenderers)
            {
                if (rend == null) continue;
                rend.SetPropertyBlock(colorPropertyBlock);
            }
        }

        // ----- Animation parameter hashes (cached to avoid per-frame string lookup) ---

        private static readonly int IsMovingHash = Animator.StringToHash("IsMoving");
        private static readonly int IsConstructingHash = Animator.StringToHash("IsConstructing");

        // ----- Per-frame update -------------------------------------------

        private void Update()
        {
            if (IsServer)
            {
                ServerDriveQueue();
                ServerStepMovement();
            }

            UpdateAnimatorState();
        }

        private void UpdateAnimatorState()
        {
            if (animator == null) return;

            Vector3 flatCurrent = new Vector3(transform.position.x, 0f, transform.position.z);
            Vector3 flatTarget = new Vector3(targetPosition.Value.x, 0f, targetPosition.Value.z);
            bool isMoving = Vector3.SqrMagnitude(flatCurrent - flatTarget)
                            > settings.arrivalThreshold * settings.arrivalThreshold;

            bool isConstructing = jobs.Count > 0 && jobs[0].Stage == BuildStage.Constructing;

            animator.SetBool(IsMovingHash, isMoving);
            animator.SetBool(IsConstructingHash, isConstructing);
        }

        private void ServerStepMovement()
        {
            Vector3 current = transform.position;
            Vector3 target = targetPosition.Value;

            // Flatten to XZ for distance/movement calculations; Y is driven by terrain raycast.
            Vector3 currentXZ = new Vector3(current.x, 0f, current.z);
            Vector3 targetXZ = new Vector3(target.x, 0f, target.z);

            Vector3 newXZ;
            bool moving;
            if (Vector3.SqrMagnitude(currentXZ - targetXZ) <= settings.arrivalThreshold * settings.arrivalThreshold)
            {
                newXZ = targetXZ;
                moving = false;
            }
            else
            {
                newXZ = Vector3.MoveTowards(currentXZ, targetXZ, settings.moveSpeed * Time.deltaTime);
                moving = true;
            }

            // Resolve Y from terrain.
            float groundY = SampleTerrainY(new Vector3(newXZ.x, 0f, newXZ.z));
            transform.position = new Vector3(newXZ.x, groundY + settings.heightOffset, newXZ.z);

            // Smoothly face the movement direction. We rotate on the server only;
            // NetworkTransform replicates the rotation to clients alongside position.
            // Skip rotation when stationary so the builder keeps its last facing.
            if (moving)
            {
                Vector3 dir = targetXZ - currentXZ;
                if (dir.sqrMagnitude > 0.0001f)
                {
                    Quaternion desired = Quaternion.LookRotation(dir, Vector3.up);
                    transform.rotation = Quaternion.RotateTowards(
                        transform.rotation, desired, settings.turnRateDegPerSec * Time.deltaTime);
                }
            }
        }

        /// <summary>
        /// Casts a ray straight down at <paramref name="xzPos"/> and returns the hit Y, or
        /// <see cref="GridCoordinates.BUILDABLE_PLANE_Y"/> if nothing was hit on the
        /// terrain layer.
        /// </summary>
        private float SampleTerrainY(Vector3 xzPos)
        {
            // Ray origin: high above the map. terrainRaycastMaxDistance defines how far to cast.
            Vector3 origin = new Vector3(xzPos.x, settings.terrainRaycastMaxDistance, xzPos.z);
            if (Physics.Raycast(origin, Vector3.down, out RaycastHit hit,
                                settings.terrainRaycastMaxDistance * 2f, settings.terrainLayerMask))
            {
                return hit.point.y;
            }

            // Fallback: builder hovers above the buildable plane.
            return GridCoordinates.BUILDABLE_PLANE_Y;
        }

        // ----- Server move API --------------------------------------------

        /// <summary>
        /// Server-side entry point: directly sets the move target. Called by the input
        /// controller's Rpc handler after validation. Out-of-map positions are rejected.
        /// </summary>
        public void ServerSetMoveTarget(Vector3 worldPos)
        {
            if (!IsServer)
            {
                Debug.LogError("[Builder] ServerSetMoveTarget called on a client.");
                return;
            }

            // Clamp to map area: convert XZ to a tile and check IsInMap.
            var loader = LevelLoader.Instance;
            if (loader != null && loader.IsLoaded)
            {
                Vector2Int tile = GridCoordinates.WorldToGrid(worldPos);
                if (!loader.IsInMap(tile))
                {
                    // Out-of-map move requests are rejected silently.
                    return;
                }
            }

            // Y is overwritten by terrain raycast each Update; we only honor X and Z here.
            targetPosition.Value = new Vector3(worldPos.x, 0f, worldPos.z);
        }

        // ----- Owner-only move RPC ----------------------------------------

        /// <summary>
        /// Owner-only Rpc: a client requests their builder move to a world position.
        /// Server validates (in-map check) and applies via <see cref="ServerSetMoveTarget"/>.
        /// </summary>
        [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
        public void RequestMoveRpc(Vector3 worldPos)
        {
            ServerSetMoveTarget(worldPos);
        }

        // ----- Range query (used by Builder's own queue driver) ----------

        /// <summary>
        /// True if the tower with the given anchor and footprint size is within build range
        /// of this builder's CURRENT position. Range is measured from builder center to the
        /// nearest point of the tower footprint, in world units.
        /// </summary>
        /// <remarks>
        /// <para>Used by <see cref="DriveHead_Queued"/> as the "have I arrived?" check —
        /// the builder walks toward a queued job until it's in range, then begins
        /// construction. NOT consulted at queue-time; players can queue any tile in their
        /// zone regardless of where the builder currently is.</para>
        ///
        /// <para>"Nearest point of the footprint" rather than "footprint center" so that
        /// a tower is reachable when ANY of its tiles is within range, even if the center
        /// is slightly outside. Aligns with player intuition that "I can reach this tile."</para>
        /// </remarks>
        public bool IsTileWithinBuildRange(Vector2Int anchor, Vector2Int footprintSize)
        {
            Vector3 builderXZ = new Vector3(transform.position.x, 0f, transform.position.z);

            // Find the point on the footprint rectangle nearest to the builder. Tile N spans
            // world [N, N+1] (edge-aligned), so a footprint at anchor (ax, ay) with size (sx, sy)
            // spans world [ax, ax+sx] × [ay, ay+sy].
            float minX = anchor.x * GridCoordinates.TILE_SIZE;
            float maxX = (anchor.x + footprintSize.x) * GridCoordinates.TILE_SIZE;
            float minZ = anchor.y * GridCoordinates.TILE_SIZE;
            float maxZ = (anchor.y + footprintSize.y) * GridCoordinates.TILE_SIZE;

            float nearestX = Mathf.Clamp(builderXZ.x, minX, maxX);
            float nearestZ = Mathf.Clamp(builderXZ.z, minZ, maxZ);
            Vector3 nearestPoint = new Vector3(nearestX, 0f, nearestZ);

            return Vector3.SqrMagnitude(builderXZ - nearestPoint)
                   <= settings.buildRange * settings.buildRange;
        }

        // ===================================================================
        // BUILD QUEUE (server-side)
        // ===================================================================

        /// <summary>
        /// Server-only: append a new job to the queue. Caller is responsible for having
        /// already validated the placement (ownership, gold, range, path) and stamped
        /// the footprint as occupied (walkable=true). Returns true on success, or false
        /// if the queue is full.
        /// </summary>
        public bool ServerEnqueueJob(Vector2Int anchor, int towerTypeId, int goldSpent,
                                     out ulong jobId)
        {
            jobId = 0;
            if (!IsServer) return false;
            if (jobs.Count >= settings.maxQueueDepth) return false;

            jobId = nextJobId++;
            var job = BuildJob.CreateQueued(jobId, anchor, towerTypeId, goldSpent);
            jobs.Add(job);

            // Spawn a green-ghost build-site visual at the anchor.
            SpawnBuildSiteVisual(job);

            return true;
        }

        private void SpawnBuildSiteVisual(BuildJob job)
        {
            if (buildSiteVisualPrefab == null)
            {
                Debug.LogError("[Builder] No buildSiteVisualPrefab assigned. " +
                               "Queued ghost will not be visible.");
                return;
            }

            var def = TowerPlacementManager.GetDefinition(job.TowerTypeId);
            if (def == null)
            {
                Debug.LogError($"[Builder] Could not resolve TowerDefinition " +
                               $"{job.TowerTypeId} when spawning build-site visual.");
                return;
            }

            Vector3 spawnPos = GridCoordinates.GetFootprintCenterWorld(
                job.Anchor, def.FootprintSize);
            // Sit on the buildable plane at the same elevation real towers will use.
            spawnPos.y = 0f;

            var go = Instantiate(buildSiteVisualPrefab, spawnPos, Quaternion.identity);
            var visual = go.GetComponent<BuildSiteVisual>();
            if (visual == null)
            {
                Debug.LogError("[Builder] buildSiteVisualPrefab is missing a " +
                               "BuildSiteVisual component.");
                Destroy(go);
                return;
            }

            PlayerSlot owner = PlayerMatchState.SlotForClient(OwnerClientId);
            visual.InitializeServer(def, owner, job.Anchor, job.TowerTypeId, job.GoldSpent);

            var netObj = go.GetComponent<NetworkObject>();
            if (netObj == null)
            {
                Debug.LogError("[Builder] buildSiteVisualPrefab is missing a NetworkObject.");
                Destroy(go);
                return;
            }
            // SpawnWithOwnership rather than plain Spawn so the visual's inherited
            // NetworkBehaviour.OwnerClientId correctly reports the player's client ID.
            // This is purely for identity (used by client-side click tests in
            // BuilderInputController) — the visual has no owner-only Rpcs, so granting
            // ownership to the player has no security implications. As a bonus, NGO
            // automatically cleans up owned NetworkObjects when a player disconnects.
            netObj.SpawnWithOwnership(OwnerClientId, destroyWithScene: true);

            jobIdToVisual[job.JobId] = netObj;
        }

        // ----- Per-tick queue drive (server) ------------------------------

        private void ServerDriveQueue()
        {
            if (jobs.Count == 0) return;

            var head = jobs[0];

            switch (head.Stage)
            {
                case BuildStage.Queued:
                    DriveHead_Queued(ref head);
                    break;

                case BuildStage.Constructing:
                    DriveHead_Constructing(ref head);
                    break;

                case BuildStage.Paused:
                    // Nothing to do — paused jobs wait for an explicit resume command.
                    // The builder is free to be moved around; targetPosition is not
                    // touched here so player right-click moves are honored.
                    break;
            }
        }

        // While the head job is Queued: walk toward the build site, checking each
        // tick whether the builder is now in range. On reaching range, re-validate
        // the path and either kick off construction or fail it. The builder doesn't
        // need to walk *onto* the build site — being within build-range is enough,
        // matching player intuition and the Wintermaul-style "reach to build" feel.
        private void DriveHead_Queued(ref BuildJob head)
        {
            var def = TowerPlacementManager.GetDefinition(head.TowerTypeId);
            if (def == null)
            {
                // Lost the definition somehow — drop the job and refund.
                Debug.LogWarning($"[Builder] Job {head.JobId} has unknown tower type " +
                                 $"{head.TowerTypeId}. Dropping and refunding.");
                ServerCancelHead(refund: true);
                return;
            }

            // If we're already in range, kick off construction immediately. Otherwise
            // walk toward the build-site center; we'll re-check range on subsequent
            // ticks. The walk target is the footprint center even though we may stop
            // before reaching it — Vector3.MoveTowards handles the early-stop naturally
            // when the range check trips.
            if (!IsTileWithinBuildRange(head.Anchor, def.FootprintSize))
            {
                Vector3 siteCenter = GridCoordinates.GetFootprintCenterWorld(
                    head.Anchor, def.FootprintSize);
                targetPosition.Value = new Vector3(siteCenter.x, 0f, siteCenter.z);
                return;
            }

            // In range. Stop here so the builder doesn't keep walking onto the
            // build site after construction kicks off. Setting target = current
            // position freezes the walk at this exact spot.
            targetPosition.Value = new Vector3(
                transform.position.x, 0f, transform.position.z);

            // Path-revalidation: the rules differ for fresh-queued vs resume-from-paused.
            //
            // FRESH QUEUE (AccumulatedConstructionTime == 0): footprint is currently
            // walkable (queue stage doesn't stamp it). Stamp it non-walkable, run BFS,
            // un-stamp on failure.
            //
            // RESUME (AccumulatedConstructionTime > 0): footprint is ALREADY non-walkable
            // (has been since the original Constructing transition; pause didn't restore
            // walkability because the half-built tower keeps blocking enemies). Don't
            // stamp anything — just verify the path still works given current grid state.
            // Path failure on resume is essentially impossible (the path was valid before
            // pause, and only this player can build in their zone), but the defensive
            // check is here in case future mechanics change that.
            var placingSlot = OwnerToSlot(OwnerClientId);
            bool isResume = head.AccumulatedConstructionTime > 0f;
            bool pathOk;
            if (isResume)
            {
                pathOk = TowerPlacementManager.Instance != null
                    && TowerPlacementManager.Instance.ServerVerifyPathStillValid(placingSlot);
            }
            else
            {
                pathOk = TowerPlacementManager.Instance != null
                    && TowerPlacementManager.Instance.ServerCommitConstructionStart(
                           head.Anchor, def.FootprintSize, placingSlot);
            }

            if (!pathOk)
            {
                // Path would now break. Refund and drop the job. The walkability
                // rules differ between fresh-queue and resume:
                //   - Fresh queue: ServerCommitConstructionStart already rolled
                //     walkable=true, so the tile is in the same state as a normal
                //     Queued job (occupied=true, walkable=true). Just clear occupancy.
                //   - Resume: walkable was never stamped this go-round but the tile
                //     IS non-walkable from the original construction. We need to
                //     restore walkability since the tower is being fully removed.
                Debug.Log($"[Builder] Job {head.JobId} dropped at construction-start: " +
                          $"path would break. Refunding {head.GoldSpent} gold.");

                var loader = LevelLoader.Instance;
                if (loader != null && loader.IsLoaded)
                {
                    foreach (var tile in GridCoordinates.GetFootprintTiles(head.Anchor, def.FootprintSize))
                    {
                        loader.SetOccupied(tile, false);
                        if (isResume) loader.SetWalkable(tile, true);
                    }
                }
                RefundJobGold(head);
                DespawnVisualAndForget(head.JobId);
                jobs.RemoveAt(0);
                return;
            }

            // Path OK; transition to Constructing.
            head.Stage = BuildStage.Constructing;
            head.ConstructionStartServerTime = (float)NetworkManager.Singleton.ServerTime.Time;
            jobs[0] = head;

            // Tell the build-site visual to switch to the constructing animation.
            // For a fresh transition AccumulatedConstructionTime is 0 (no previous progress);
            // for a resume it carries the elapsed time from prior runs so the visual
            // and the elapsed-time computation pick up where they left off.
            if (jobIdToVisual.TryGetValue(head.JobId, out var visualObj) && visualObj != null)
            {
                var visual = visualObj.GetComponent<BuildSiteVisual>();
                if (visual != null) visual.ServerBeginConstructing(head.AccumulatedConstructionTime);
            }
        }

        // While the head job is Constructing: wait for buildTime to elapse, then
        // promote the build-site to a real TowerInstance and dequeue.
        private void DriveHead_Constructing(ref BuildJob head)
        {
            var def = TowerPlacementManager.GetDefinition(head.TowerTypeId);
            if (def == null)
            {
                // Should be impossible — definitions are validated at queue-time.
                Debug.LogError($"[Builder] Constructing job {head.JobId} has unknown " +
                               $"tower type {head.TowerTypeId}. Dropping.");
                ServerCancelHead(refund: true);
                return;
            }

            // Total construction progress = elapsed in current run + previously accumulated
            // (across prior pause/resume cycles). Pause sets ConstructionStartServerTime to -1
            // so this code path only runs when the job is genuinely Constructing — but defensive
            // check anyway.
            float currentRunElapsed = head.ConstructionStartServerTime > 0f
                ? (float)NetworkManager.Singleton.ServerTime.Time - head.ConstructionStartServerTime
                : 0f;
            float total = currentRunElapsed + head.AccumulatedConstructionTime;
            if (total < def.BuildTime) return;

            // Construction complete. Spawn the real TowerInstance, despawn the visual,
            // remove the job. The TowerInstance will stamp walkability=false / occupancy=true
            // in its OnNetworkSpawn — but those bits are ALREADY non-walkable / occupied
            // from the construction-start commit, so the stamp is a no-op-equivalent
            // (idempotent writes; see TowerInstance.cs comments).
            var placingSlot = OwnerToSlot(OwnerClientId);
            TowerPlacementManager.Instance?.ServerSpawnCompletedTower(
                def, head.Anchor, placingSlot);

            // Despawn the build-site visual.
            if (jobIdToVisual.TryGetValue(head.JobId, out var visualObj))
            {
                if (visualObj != null && visualObj.IsSpawned)
                    visualObj.Despawn(destroy: true);
                jobIdToVisual.Remove(head.JobId);
            }

            // Pop the head.
            jobs.RemoveAt(0);

            Debug.Log($"[Builder] Job {head.JobId} complete at anchor {head.Anchor}.");
        }

        // ----- Cancellation API -------------------------------------------

        /// <summary>
        /// Owner-only Rpc: cancel every job in this builder's queue. Each cancellation
        /// refunds 100% of the gold paid (per the design doc) and frees the affected
        /// tiles. Builder stops at its current position.
        /// </summary>
        [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
        public void RequestCancelAllJobsRpc()
        {
            if (!IsServer) return;
            ServerCancelAllJobs();
        }

        /// <summary>
        /// Server-side: cancel every job in the queue. Public so other server code
        /// (disconnect cleanup, future "level reset" events) can invoke it directly.
        /// </summary>
        public void ServerCancelAllJobs()
        {
            // Cancel in reverse so RemoveAt indices stay valid as we go.
            while (jobs.Count > 0)
            {
                ServerCancelJobAt(jobs.Count - 1);
            }

            // Stop walking. The current position becomes the new target so the builder
            // settles at the spot it cancelled at, rather than drifting toward the
            // anchor of the (now-removed) head job.
            targetPosition.Value = new Vector3(
                transform.position.x, 0f, transform.position.z);
        }

        // ===================================================================
        // PAUSE / SHELVE / RESUME (D2 — paused builds are shelved off the queue)
        // ===================================================================

        /// <summary>
        /// Owner-only Rpc: the player issued a "move to here" command (right-click
        /// on empty buildable plane). Server applies the new move target and:
        /// <list type="bullet">
        ///   <item>If head is Constructing → pause + shelve the head. The visual
        ///         remains in the world as a shelved standalone object; the queue
        ///         loses this entry. Tail jobs are refunded.</item>
        ///   <item>If head is Queued → refund the entire queue (no progress to preserve).</item>
        ///   <item>Empty queue → just a move command. No queue effects.</item>
        /// </list>
        /// </summary>
        [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
        public void RequestMoveAndPauseRpc(Vector3 worldPos)
        {
            if (!IsServer) return;

            // Step 1: queue effects (if any).
            if (jobs.Count > 0)
            {
                var head = jobs[0];
                if (head.Stage == BuildStage.Constructing)
                {
                    // Pause + shelve the head, then refund tail jobs.
                    ServerPauseAndShelveHead();
                    ServerRefundAllRemainingJobs();
                }
                else if (head.Stage == BuildStage.Queued)
                {
                    // Head never reached Constructing — refund everything.
                    ServerCancelAllJobs();
                }
                // Note: Paused-in-queue is no longer a state we can be in (paused
                // jobs are immediately shelved and removed from jobs[]). If we
                // somehow get here, treat it like Constructing for safety.
            }

            // Step 2: apply the move target.
            ServerSetMoveTarget(worldPos);
        }

        /// <summary>
        /// Owner-only Rpc: the player right-clicked a shelved build site to resume it.
        /// Server pre-empts the current queue (shelves any active head, refunds all
        /// remaining jobs), then unshelves the clicked visual into a new BuildJob at
        /// index 0. Builder will walk back into range and resume construction with
        /// preserved accumulated time.
        /// </summary>
        [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
        public void RequestResumeShelvedRpc(NetworkObjectReference visualRef)
        {
            if (!IsServer) return;

            // Resolve the visual NetworkObject from the reference.
            if (!visualRef.TryGet(out NetworkObject visualNetObj) || visualNetObj == null)
            {
                Debug.LogWarning("[Builder] RequestResumeShelvedRpc: visual not found.");
                return;
            }
            var visual = visualNetObj.GetComponent<BuildSiteVisual>();
            if (visual == null || !visual.IsShelved)
            {
                Debug.LogWarning("[Builder] RequestResumeShelvedRpc: visual is not shelved.");
                return;
            }
            if (visualNetObj.OwnerClientId != OwnerClientId)
            {
                Debug.LogWarning($"[Builder] RequestResumeShelvedRpc: visual owner " +
                                 $"{visualNetObj.OwnerClientId} != builder owner {OwnerClientId}.");
                return;
            }

            // Step 1: pre-empt the current queue.
            //   - If head is Constructing: pause+shelve it (it becomes a new shelved tower).
            //   - Otherwise (Queued head, or empty): refund everything.
            // The clicked visual is NOT in the queue (it's shelved), so this loop
            // doesn't touch it.
            if (jobs.Count > 0)
            {
                var head = jobs[0];
                if (head.Stage == BuildStage.Constructing)
                {
                    ServerPauseAndShelveHead();
                    ServerRefundAllRemainingJobs();
                }
                else
                {
                    ServerCancelAllJobs();
                }
            }

            // Step 2: unshelve the clicked visual. Reconstruct a BuildJob from its
            // NetworkVariables, push it as the new head of the queue, and re-register
            // it in jobIdToVisual so the queue driver finds it.
            ulong jobId = nextJobId++;
            var job = BuildJob.CreateQueued(
                jobId,
                visual.Anchor,
                visual.TowerTypeId,
                visual.GoldSpent);
            // Carry the accumulated time forward — the resume keeps prior progress.
            job.AccumulatedConstructionTime = visual.AccumulatedConstructionTime;
            jobs.Add(job);

            jobIdToVisual[jobId] = visualNetObj;
            visual.ServerMarkUnshelved();

            // Note: The visual's currentStage is still Paused; the queue driver's
            // DriveHead_Queued will call ServerBeginConstructing once the builder
            // arrives, which transitions it to Constructing.

            Debug.Log($"[Builder] Resumed shelved tower at {visual.Anchor} as job " +
                      $"{jobId} (accumulated {visual.AccumulatedConstructionTime:F2}s).");
        }

        // ----- Pause/shelve/refund helpers --------------------------------

        // Pauses + shelves the head job: Constructing → Paused, then removes from
        // jobs[] and jobIdToVisual. The visual stays in the world as a standalone
        // shelved object. Player can later right-click it to resume.
        private void ServerPauseAndShelveHead()
        {
            if (jobs.Count == 0) return;
            var head = jobs[0];
            if (head.Stage != BuildStage.Constructing) return;

            // Compute current run's elapsed and add to accumulated.
            float currentRunElapsed = head.ConstructionStartServerTime > 0f
                ? (float)NetworkManager.Singleton.ServerTime.Time - head.ConstructionStartServerTime
                : 0f;
            float totalAccumulated = head.AccumulatedConstructionTime + currentRunElapsed;

            // Tell the visual to enter Paused state, write the accumulated time,
            // and mark itself as shelved. After this, the visual carries all the
            // state that used to live on the BuildJob.
            if (jobIdToVisual.TryGetValue(head.JobId, out var visualObj) && visualObj != null)
            {
                var visual = visualObj.GetComponent<BuildSiteVisual>();
                if (visual != null)
                {
                    visual.ServerPauseAndPersistAccumulated(totalAccumulated);
                    visual.ServerMarkShelved();
                }
                // Remove from our visual tracking — the visual is now self-managing.
                jobIdToVisual.Remove(head.JobId);
            }

            // Pop the head from the queue. The visual stays in the world.
            jobs.RemoveAt(0);

            Debug.Log($"[Builder] Job {head.JobId} paused and shelved at " +
                      $"{totalAccumulated:F2}s of construction.");
        }

        // Refunds and removes EVERY job in the queue. Used after pause+shelve to
        // clear out tail jobs (the shelved head is no longer in jobs[] at this point).
        // Same as ServerCancelAllJobs but doesn't reset targetPosition (caller does that
        // by setting an explicit move target afterward).
        private void ServerRefundAllRemainingJobs()
        {
            for (int i = jobs.Count - 1; i >= 0; i--)
            {
                ServerCancelJobAt(i);
            }
        }

        // ----- Public state accessor (read by the input controller) -------

        // Note: PausedHeadJobId was removed when paused jobs moved out of the queue
        // entirely. The input controller now uses the BuildSiteVisual's own state
        // (visual.IsShelved + ownership) to determine resumeability and sends the
        // visual's NetworkObjectReference to RequestResumeShelvedRpc.

        // Cancels the head job specifically. Used for the "definition lost" defensive
        // cases where the TowerDefinition can't be resolved. Restores walkability
        // appropriately based on the current stage of the job being cancelled.
        private void ServerCancelHead(bool refund)
        {
            if (jobs.Count == 0) return;

            var head = jobs[0];
            var def = TowerPlacementManager.GetDefinition(head.TowerTypeId);
            if (def != null)
            {
                // Constructing or Paused → tile is non-walkable, restore it.
                // Queued → tile was never made non-walkable, leave alone.
                bool wasBlocking = head.Stage == BuildStage.Constructing
                                || head.Stage == BuildStage.Paused;
                FreeFootprintTiles(head.Anchor, def.FootprintSize,
                                   alsoMakeWalkable: wasBlocking);
            }
            if (refund) RefundJobGold(head);
            DespawnVisualAndForget(head.JobId);
            jobs.RemoveAt(0);
        }

        /// <summary>
        /// Server-only: cancel the job in this builder's queue whose anchor matches
        /// <paramref name="targetAnchor"/>. Refunds gold, frees the footprint tiles
        /// (restoring walkability if the stage was blocking), and despawns the
        /// build-site visual. Returns true if a matching job was found and
        /// cancelled. Used by <see cref="BuildSiteVisual.RequestCancelRpc"/> so the
        /// player can cancel a specific in-progress build from the HUD without
        /// affecting other queued/constructing builds.
        /// </summary>
        public bool ServerCancelJobAtAnchor(Vector2Int targetAnchor)
        {
            if (!IsServer) return false;
            for (int i = 0; i < jobs.Count; i++)
            {
                if (jobs[i].Anchor == targetAnchor)
                {
                    ServerCancelJobAt(i);
                    return true;
                }
            }
            return false;
        }

        // Cancels the job at index i. Used for cancel-all and targeted cancel paths.
        private void ServerCancelJobAt(int index)
        {
            if (index < 0 || index >= jobs.Count) return;

            var job = jobs[index];
            var def = TowerPlacementManager.GetDefinition(job.TowerTypeId);
            if (def != null)
            {
                // Walkability state by stage:
                //   - Queued: walkable (queue stage doesn't stamp walkability) → don't touch.
                //   - Constructing: non-walkable (stamped at construction-start) → restore.
                //   - Paused: non-walkable (stamped at original construction-start, never
                //             reverted because the half-built tower keeps blocking) → restore.
                bool wasBlocking = job.Stage == BuildStage.Constructing
                                || job.Stage == BuildStage.Paused;
                FreeFootprintTiles(job.Anchor, def.FootprintSize,
                                   alsoMakeWalkable: wasBlocking);
            }

            RefundJobGold(job);
            DespawnVisualAndForget(job.JobId);
            jobs.RemoveAt(index);
        }

        private void FreeFootprintTiles(Vector2Int anchor, Vector2Int footprint,
                                        bool alsoMakeWalkable)
        {
            var loader = LevelLoader.Instance;
            if (loader == null || !loader.IsLoaded) return;

            foreach (var t in GridCoordinates.GetFootprintTiles(anchor, footprint))
            {
                loader.SetOccupied(t, false);
                if (alsoMakeWalkable) loader.SetWalkable(t, true);
            }
        }

        private void RefundJobGold(BuildJob job)
        {
            var goldManager = PlayerGoldManager.GetForClient(OwnerClientId);
            if (goldManager == null) return;
            goldManager.AwardGold(job.GoldSpent);
        }

        private void DespawnVisualAndForget(ulong jobId)
        {
            if (!jobIdToVisual.TryGetValue(jobId, out var visualObj)) return;
            if (visualObj != null && visualObj.IsSpawned)
                visualObj.Despawn(destroy: true);
            jobIdToVisual.Remove(jobId);
        }

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

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

        private static Vector2Int ResolveFootprint(int towerTypeId)
        {
            var def = TowerPlacementManager.GetDefinition(towerTypeId);
            return def != null ? def.FootprintSize : new Vector2Int(2, 2);
        }
    }
}