// Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Towers;

namespace TD.Gameplay
{
    /// <summary>
    /// Visual representation of an in-flight <see cref="BuildJob"/>: the green
    /// queued ghost, and the staged construction animation (4 stages of growing
    /// height for the testing cube). One NetworkObject per active job. Despawned
    /// when the job is cancelled or when the real <see cref="TowerInstance"/>
    /// takes its place at construction-complete.
    /// </summary>
    /// <remarks>
    /// <para><b>Why a separate prefab from TowerInstance.</b> The build-site visual
    /// has different rendering (transparent green or partial-height cube), no combat,
    /// no grid-stamping (the Builder owns those state transitions), and a much
    /// shorter lifecycle. Sharing a prefab with TowerInstance would mean adding
    /// "am I real or a ghost" branching to every TowerInstance code path. Two
    /// prefabs, two responsibilities.</para>
    ///
    /// <para><b>Stage replication.</b> Stage and ConstructionStartServerTime are
    /// replicated as NetworkVariables so all peers compute identical visuals locally
    /// from <c>NetworkManager.ServerTime.TimeAsFloat</c>. Only the server writes;
    /// clients read.</para>
    ///
    /// <para><b>Visual model.</b> The prefab inspector points at a re-tinted copy
    /// of the tower's mesh (the same testing cube). At Stage = Queued, the visual
    /// is a translucent green cube at full footprint scale but reduced Y. At
    /// Stage = Constructing, the cube grows in 4 sub-stages over BuildTime.</para>
    ///
    /// <para><b>No grid stamping here.</b> Walkability and occupancy stamps are
    /// driven by the Builder (queue-time stamps occupied=true / walkable=true,
    /// construction-start stamps walkable=false, completion is unchanged because
    /// TowerInstance takes over). Adding stamping here would create double-write
    /// races with the Builder. See lessons in the project context doc.</para>
    /// </remarks>
    [RequireComponent(typeof(NetworkObject))]
    public class BuildSiteVisual : NetworkBehaviour, ISelectable
    {
        // ----- Inspector --------------------------------------------------

        [Header("Visuals")]
        [Tooltip("Renderers tinted with the queued-ghost color. Typically every " +
                 "MeshRenderer on the prefab. Auto-populated from children if empty.")]
        [SerializeField] private MeshRenderer[] tintedRenderers;

        [Tooltip("Transform that gets Y-scaled to represent construction progress. " +
                 "Typically the visual mesh's transform. The growth axis is local Y.")]
        [SerializeField] private Transform scaleTarget;

        [Tooltip("Material applied while the job is Queued (translucent green).")]
        [SerializeField] private Material queuedMaterial;

        [Tooltip("Material applied while the job is Constructing (opaque, tinted by owner).")]
        [SerializeField] private Material constructingMaterial;

        [Tooltip("Material applied while the job is Paused. Visually distinct from " +
                 "Constructing so players can tell at a glance which builds need a builder. " +
                 "Suggested: muted/grey-tinted variant of the constructing material.")]
        [SerializeField] private Material pausedMaterial;

        [Header("Construction phases")]
        [Tooltip("Project-default construction-stage visuals used when the tower being built " +
                 "doesn't define its own ConstructionPhases. When this (or the tower's set) " +
                 "has phases, the build site swaps between those phase prefabs as it builds. " +
                 "Leave empty to fall back to the legacy cube Y-scale growth below.")]
        [SerializeField] private ConstructionPhaseSet defaultPhaseSet;

        [Header("Construction stages (cube fallback)")]
        [Tooltip("FALLBACK ONLY (no phase set assigned): number of discrete growth stages " +
                 "while constructing. 4 = 1/4 → 2/4 → 3/4 → 4/4 height.")]
        [SerializeField] private int stageCount = 4;

        [Tooltip("FALLBACK ONLY: Y-scale applied to scaleTarget when Stage == Queued. " +
                 "Visually distinct from any constructing height so the queued ghost reads " +
                 "as 'intent, not progress'.")]
        [SerializeField] private float queuedYScale = 0.15f;

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

        // Replicated owner slot for color tinting. Mirrors TowerInstance.
        private readonly NetworkVariable<PlayerSlot> ownerSlot =
            new NetworkVariable<PlayerSlot>(
                PlayerSlot.None,
                readPerm: NetworkVariableReadPermission.Everyone,
                writePerm: NetworkVariableWritePermission.Server);

        // Anchor tile (SW corner of the footprint, world-tile coords). Replicated so
        // shelved visuals are self-describing — when a player clicks one to resume,
        // the server can rebuild a BuildJob from these fields without consulting any
        // separate registry.
        private readonly NetworkVariable<Vector2Int> anchor =
            new NetworkVariable<Vector2Int>(
                Vector2Int.zero,
                readPerm: NetworkVariableReadPermission.Everyone,
                writePerm: NetworkVariableWritePermission.Server);

        // Tower type ID (index into TowerPlacementManager.towerDefinitions[]). Replicated
        // for the same self-describing reason as Anchor.
        private readonly NetworkVariable<int> towerTypeId =
            new NetworkVariable<int>(
                0,
                readPerm: NetworkVariableReadPermission.Everyone,
                writePerm: NetworkVariableWritePermission.Server);

        // Gold the player paid to queue this build. Carried on the visual so resume
        // can refund the correct amount if the player later cancels. (Cancellation
        // gesture deferred to HUD; this field is the data dependency.)
        private readonly NetworkVariable<int> goldSpent =
            new NetworkVariable<int>(
                0,
                readPerm: NetworkVariableReadPermission.Everyone,
                writePerm: NetworkVariableWritePermission.Server);

        // True iff this build site has been "shelved" — removed from its owning Builder's
        // queue and now a standalone object waiting to be resumed via right-click.
        // Shelved visuals are responsible for their own grid-state cleanup on despawn
        // (since the Builder no longer tracks them in jobIdToVisual).
        private readonly NetworkVariable<bool> isShelved =
            new NetworkVariable<bool>(
                false,
                readPerm: NetworkVariableReadPermission.Everyone,
                writePerm: NetworkVariableWritePermission.Server);

        // Current stage. Drives material swap and Y-scale animation.
        private readonly NetworkVariable<BuildStage> currentStage =
            new NetworkVariable<BuildStage>(
                BuildStage.Queued,
                readPerm: NetworkVariableReadPermission.Everyone,
                writePerm: NetworkVariableWritePermission.Server);

        // Server time at which the current Constructing run began. -1 while Queued or Paused.
        // Used together with accumulatedConstructionTime to compute total progress:
        // total = (now - constructionStartServerTime) + accumulatedConstructionTime.
        private readonly NetworkVariable<float> constructionStartServerTime =
            new NetworkVariable<float>(
                -1f,
                readPerm: NetworkVariableReadPermission.Everyone,
                writePerm: NetworkVariableWritePermission.Server);

        // Construction time accumulated across previous Constructing runs (for pause/resume).
        // 0 for jobs that have never been paused. At pause, set to the elapsed time of the
        // current run added to whatever was already accumulated.
        private readonly NetworkVariable<float> accumulatedConstructionTime =
            new NetworkVariable<float>(
                0f,
                readPerm: NetworkVariableReadPermission.Everyone,
                writePerm: NetworkVariableWritePermission.Server);

        // BuildTime is replicated rather than looked up so clients don't need to
        // resolve the TowerDefinition before they can render progress.
        // (Resolution can race with the first stage update otherwise.)
        private readonly NetworkVariable<float> buildTime =
            new NetworkVariable<float>(
                0f,
                readPerm: NetworkVariableReadPermission.Everyone,
                writePerm: NetworkVariableWritePermission.Server);

        // ----- Public accessors (read by the input controller on click) --

        // NOTE: ownership identity comes from the inherited NetworkBehaviour.OwnerClientId,
        // which is correct because we use SpawnWithOwnership in Builder.SpawnBuildSiteVisual.
        // No redundant NetworkVariable for ownership.

        /// <summary>The current build stage. Read by clients for click-target tests.</summary>
        public BuildStage CurrentStage => currentStage.Value;

        /// <summary>True iff this visual has been shelved (no longer in its Builder's queue).</summary>
        public bool IsShelved => isShelved.Value;

        /// <summary>Footprint anchor (SW corner). Used by server to rebuild a BuildJob on resume.</summary>
        public Vector2Int Anchor => anchor.Value;

        /// <summary>Tower type ID. Used by server to rebuild a BuildJob on resume.</summary>
        public int TowerTypeId => towerTypeId.Value;

        /// <summary>Gold paid for this build. Used by server to rebuild a BuildJob on resume.</summary>
        public int GoldSpent => goldSpent.Value;

        /// <summary>Accumulated construction time (across pause/resume cycles). Used on resume.</summary>
        public float AccumulatedConstructionTime => accumulatedConstructionTime.Value;

        /// <summary>
        /// Returns [0,1] normalized construction progress. Safe to call on any client.
        /// Returns 0 while Queued; freezes at accumulated fraction while Paused.
        /// </summary>
        public float ComputeProgressNormalized()
        {
            float bt = buildTime.Value;
            if (bt <= 0f) return 0f;
            float currentRunElapsed = constructionStartServerTime.Value > 0f
                ? (float)NetworkManager.Singleton.ServerTime.Time - constructionStartServerTime.Value
                : 0f;
            return Mathf.Clamp01((currentRunElapsed + accumulatedConstructionTime.Value) / bt);
        }

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

        /// <summary>Name shown in the HUD portrait. Pulls from the resolved
        /// TowerDefinition; falls back to a generic label if the registry hasn't
        /// resolved the def yet on this client.</summary>
        public string DisplayName
        {
            get
            {
                var def = TowerPlacementManager.GetDefinition(towerTypeId.Value);
                return def != null ? def.DisplayName : "Tower (building)";
            }
        }

        public SelectableKind Kind => SelectableKind.BuildSite;

        public Transform SelectionTransform => transform;

        // Match the TowerInstance's radius formula so the selection ring is the
        // same size before vs. after the build completes — no visual pop on transition.
        public float SelectionRadius
        {
            get
            {
                var def = TowerPlacementManager.GetDefinition(towerTypeId.Value);
                Vector2Int fp = def != null ? def.FootprintSize : new Vector2Int(1, 1);
                return Mathf.Max(fp.x, fp.y) * 0.5f * GridCoordinates.TILE_SIZE + 0.5f;
            }
        }

        // ----- Pre-spawn init data (server) -------------------------------

        private PlayerSlot pendingOwner = PlayerSlot.None;
        private float pendingBuildTime;
        private Vector2Int pendingAnchor;
        private int pendingTowerTypeId;
        private int pendingGoldSpent;
        private bool hasPendingInit;

        // ----- Construction-phase runtime (local, all peers) --------------

        // Resolved on spawn from the tower's ConstructionPhases or the defaultPhaseSet.
        // When non-null with PhaseCount > 0, the build site swaps between these
        // instantiated phase prefabs instead of Y-scaling the fallback cube.
        private ConstructionPhaseSet effectivePhaseSet;
        private GameObject[] phaseInstances;      // one instantiated child per phase
        private MeshRenderer[][] phaseRenderers;  // cached renderers per phase, for tinting
        private int activePhaseIndex = -1;
        private bool usePhases;

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

        /// <summary>
        /// Server-only: stores the data that <see cref="OnNetworkSpawn"/> will write
        /// into NetworkVariables. Must be called between Instantiate and Spawn().
        /// </summary>
        /// <remarks>
        /// Owner identity is conveyed through NGO ownership (via SpawnWithOwnership),
        /// not through this method — see <see cref="NetworkBehaviour.OwnerClientId"/>.
        /// </remarks>
        public void InitializeServer(TowerDefinition def, PlayerSlot owner,
                                     Vector2Int anchorTile, int towerTypeIdValue,
                                     int goldSpentValue)
        {
            var nm = NetworkManager.Singleton;
            if (nm == null || !nm.IsServer)
            {
                Debug.LogError("[BuildSiteVisual] InitializeServer called when not running as server.");
                return;
            }

            pendingOwner = owner;
            pendingBuildTime = def != null ? def.BuildTime : 0f;
            pendingAnchor = anchorTile;
            pendingTowerTypeId = towerTypeIdValue;
            pendingGoldSpent = goldSpentValue;
            hasPendingInit = true;
        }

        public override void OnNetworkSpawn()
        {
            // Auto-populate tinted renderers if not configured in the inspector.
            if (tintedRenderers == null || tintedRenderers.Length == 0)
                tintedRenderers = GetComponentsInChildren<MeshRenderer>();

            // Server: now that the NetworkObject is spawned, write the pending init
            // values into NetworkVariables. NGO captures these into the initial sync
            // message so clients see correct values on their first OnNetworkSpawn.
            if (IsServer && hasPendingInit)
            {
                ownerSlot.Value = pendingOwner;
                buildTime.Value = pendingBuildTime;
                anchor.Value = pendingAnchor;
                towerTypeId.Value = pendingTowerTypeId;
                goldSpent.Value = pendingGoldSpent;
                hasPendingInit = false;
            }

            // Resolve and instantiate the construction-phase visuals (or fall back to the
            // cube). Depends on towerTypeId, which is replicated by now on every peer.
            ResolvePhaseSet();

            // Subscribe to value changes so visual updates are reactive.
            currentStage.OnValueChanged += HandleStageChanged;

            // Apply initial visual state based on the (now-replicated) values.
            ApplyStageVisual(currentStage.Value);
        }

        public override void OnNetworkDespawn()
        {
            currentStage.OnValueChanged -= HandleStageChanged;

            // Server-only cleanup: if this visual was shelved at the time it was
            // despawned (e.g., the player disconnected while a tower was shelved),
            // restore walkability and occupancy on the footprint. Non-shelved
            // visuals are owned by their Builder, which handles cleanup via
            // jobIdToVisual; we mustn't double-free in that case.
            if (IsServer && isShelved.Value)
            {
                RestoreFootprintGridState();
            }

            // Local-only selection hygiene. If this visual was the active selection
            // AND nothing has transferred selection to a replacement (e.g., a
            // TowerInstance that just spawned at the same anchor), clear so HUD/
            // visualizer don't hold a destroyed reference. The completion path
            // spawns TowerInstance BEFORE this despawn arrives on the client; that
            // spawn already transferred selection, so this check passes through.
            if (SelectionState.Instance != null && SelectionState.Instance.IsSelected(this))
                SelectionState.Instance.Clear();
        }

        // Server-only: restore walkability=true and occupancy=false on this build site's
        // footprint. Called when a shelved visual is despawned without going through a
        // normal "resume → cancel" cycle (e.g., player disconnect cleanup).
        private void RestoreFootprintGridState()
        {
            var loader = LevelLoader.Instance;
            if (loader == null || !loader.IsLoaded) return;

            var def = TowerPlacementManager.GetDefinition(towerTypeId.Value);
            if (def == null) return;

            foreach (var tile in GridCoordinates.GetFootprintTiles(
                                     anchor.Value, def.FootprintSize))
            {
                loader.SetOccupied(tile, false);
                loader.SetWalkable(tile, true);
            }
        }

        // ----- Per-frame visual update (all peers) ------------------------

        private void Update()
        {
            // While constructing, advance the visual based on server time. Runs on every
            // peer so visuals stay synchronized. Paused does NOT update — the visual is
            // frozen at the pause point.
            if (currentStage.Value != BuildStage.Constructing) return;

            if (usePhases)
            {
                int index = PhaseIndexFromProgress();
                if (index != activePhaseIndex)
                {
                    ShowPhase(index);
                    // Renderers changed with the swap — re-apply material + owner tint.
                    ApplyStageMaterialAndTint(BuildStage.Constructing);
                }
                return;
            }

            // Fallback: smoothly grow the cube's Y-scale through the stages.
            ApplyYScale(ComputeConstructingYScale());
        }

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

        /// <summary>
        /// Server-only: marks this visual as shelved. The visual remains in the world
        /// but no longer belongs to the owning Builder's queue. Stage stays Paused;
        /// this method just flips the IsShelved flag so the visual takes responsibility
        /// for its own grid-state cleanup on despawn.
        /// </summary>
        public void ServerMarkShelved()
        {
            if (!IsServer) return;
            isShelved.Value = true;
        }

        /// <summary>
        /// Server-only: marks this visual as unshelved (taken back into a Builder's queue).
        /// Called when the player right-clicks a shelved visual to resume construction.
        /// Grid-state cleanup is once again the Builder's responsibility via jobIdToVisual.
        /// </summary>
        public void ServerMarkUnshelved()
        {
            if (!IsServer) return;
            isShelved.Value = false;
        }

        // ----- Player-initiated cancel (HUD action) -----------------------

        // Server-only guard against double cancel — Cancel RPC could arrive twice
        // if the player clicks quickly before the visual finishes despawning.
        private bool serverCancelled;

        /// <summary>
        /// Owner-only RPC. Routes to <see cref="ServerCancel"/>. Hooked up to the
        /// Cancel action button in the HUD's action menu.
        /// </summary>
        [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
        public void RequestCancelRpc()
        {
            ServerCancel();
        }

        /// <summary>
        /// Server-only: cancel this build. Two paths depending on shelve state:
        ///   - <b>Shelved</b>: this visual is standalone (not in any builder's queue).
        ///     Refund gold ourselves and despawn — <see cref="OnNetworkDespawn"/>
        ///     restores the footprint's grid state because <c>isShelved</c> is true.
        ///   - <b>In a builder's queue</b>: route through the owning builder's
        ///     <see cref="Builder.ServerCancelJobAtAnchor"/>, which already handles
        ///     refund + grid restore + visual despawn through its job-cleanup path.
        /// </summary>
        public void ServerCancel()
        {
            if (!IsServer) return;
            if (serverCancelled) return;            // idempotent guard
            serverCancelled = true;

            if (isShelved.Value)
            {
                RefundOwner();
                if (NetworkObject.IsSpawned)
                    NetworkObject.Despawn(destroy: true);
                return;
            }

            // Queued / Constructing / Paused — owned by a builder's job queue.
            var builder = Builder.GetForClient(OwnerClientId);
            if (builder != null)
            {
                bool found = builder.ServerCancelJobAtAnchor(anchor.Value);
                if (found) return;
                // Race: visual exists but no matching job (e.g., job just completed
                // and the visual is mid-despawn). Fall through to manual cleanup.
                Debug.LogWarning($"[BuildSiteVisual] ServerCancel: no matching job " +
                                 $"at anchor {anchor.Value} on builder for client " +
                                 $"{OwnerClientId}. Performing manual refund+despawn.");
            }
            else
            {
                Debug.LogWarning("[BuildSiteVisual] ServerCancel: owning builder " +
                                 "not found. Performing manual refund+despawn.");
            }

            // Manual fallback for the race / no-builder cases. Restore grid since
            // the builder isn't going to do it for us.
            RefundOwner();
            if (currentStage.Value == BuildStage.Constructing
                || currentStage.Value == BuildStage.Paused)
            {
                RestoreFootprintGridState();
            }
            if (NetworkObject.IsSpawned)
                NetworkObject.Despawn(destroy: true);
        }

        private void RefundOwner()
        {
            var goldManager = PlayerGoldManager.GetForClient(OwnerClientId);
            if (goldManager == null) return;
            goldManager.AwardGold(goldSpent.Value);
        }

        /// <summary>
        /// Server-only: transitions the visual from Queued (or Paused) to Constructing
        /// and records the server time for stage progression. Caller is responsible
        /// for setting <paramref name="accumulatedTimeBeforeThisRun"/> from the BuildJob's
        /// AccumulatedConstructionTime — non-zero values mean this is a resume.
        /// </summary>
        public void ServerBeginConstructing(float accumulatedTimeBeforeThisRun)
        {
            if (!IsServer) return;
            if (currentStage.Value == BuildStage.Constructing) return;

            accumulatedConstructionTime.Value = accumulatedTimeBeforeThisRun;
            constructionStartServerTime.Value =
                (float)NetworkManager.Singleton.ServerTime.Time;
            currentStage.Value = BuildStage.Constructing;
        }

        /// <summary>
        /// Server-only: transitions Constructing → Paused AND writes the new
        /// accumulated construction time. Y-scale freezes at the level matching
        /// the accumulated time. After this returns, the visual is fully self-
        /// describing — it carries enough state to be shelved and later resumed.
        /// </summary>
        public void ServerPauseAndPersistAccumulated(float totalAccumulated)
        {
            if (!IsServer) return;
            if (currentStage.Value != BuildStage.Constructing) return;

            accumulatedConstructionTime.Value = totalAccumulated;
            // Reset constructionStartServerTime to -1 so any future progress reads
            // know there's no active timer running.
            constructionStartServerTime.Value = -1f;
            currentStage.Value = BuildStage.Paused;
        }

        // ----- Visual state machine ---------------------------------------

        private void HandleStageChanged(BuildStage previous, BuildStage current)
        {
            ApplyStageVisual(current);
        }

        private void ApplyStageVisual(BuildStage stage)
        {
            if (usePhases)
            {
                // Queued shows the first phase; constructing/paused show the phase
                // matching current progress. ShowPhase repoints tintedRenderers at the
                // active phase instance, so material + tint are applied AFTER it.
                int index = stage == BuildStage.Queued ? 0 : PhaseIndexFromProgress();
                ShowPhase(index);
                ApplyStageMaterialAndTint(stage);
                return;
            }

            // Fallback: legacy cube Y-scale growth (no phase set wired).
            ApplyStageMaterialAndTint(stage);
            switch (stage)
            {
                case BuildStage.Queued:       ApplyYScale(queuedYScale); break;
                case BuildStage.Constructing: ApplyYScale(ComputeConstructingYScale()); break;
                // ComputePausedYScale uses accumulatedConstructionTime alone when
                // constructionStartServerTime is -1 (the pause sentinel).
                case BuildStage.Paused:       ApplyYScale(ComputePausedYScale()); break;
            }
        }

        // Applies the stage's material + owner tint to whatever renderers are currently
        // active — the cube fallback, or the active phase instance's renderers. Paused
        // falls back to the constructing material if no paused material is assigned.
        private void ApplyStageMaterialAndTint(BuildStage stage)
        {
            switch (stage)
            {
                case BuildStage.Queued:
                    SwapMaterial(queuedMaterial);   // green ghost — no owner tint
                    break;
                case BuildStage.Constructing:
                    SwapMaterial(constructingMaterial);
                    ApplyOwnerTint();
                    break;
                case BuildStage.Paused:
                    SwapMaterial(pausedMaterial != null ? pausedMaterial : constructingMaterial);
                    ApplyOwnerTint();
                    break;
            }
        }

        // ----- Construction phases ----------------------------------------

        // Resolves the effective phase set (tower's own, else the project default) and
        // instantiates one inactive child per phase. Sets usePhases=false (cube fallback)
        // if neither set has any phases. Runs on every peer in OnNetworkSpawn.
        private void ResolvePhaseSet()
        {
            var def = TowerPlacementManager.GetDefinition(towerTypeId.Value);
            effectivePhaseSet =
                (def != null && def.ConstructionPhases != null && def.ConstructionPhases.PhaseCount > 0)
                    ? def.ConstructionPhases
                    : defaultPhaseSet;

            int n = effectivePhaseSet != null ? effectivePhaseSet.PhaseCount : 0;
            if (n <= 0)
            {
                usePhases = false;
                return;
            }

            usePhases     = true;
            phaseInstances = new GameObject[n];
            phaseRenderers = new MeshRenderer[n][];

            // Hide the legacy fallback cube — the instantiated phase prefabs replace it.
            if (scaleTarget != null) scaleTarget.gameObject.SetActive(false);

            for (int i = 0; i < n; i++)
            {
                var prefab = effectivePhaseSet.GetPhase(i);
                if (prefab == null) continue;

                var go = Instantiate(prefab, transform);
                go.transform.localPosition = Vector3.zero;
                go.transform.localRotation = Quaternion.identity;
                go.SetActive(false);

                phaseInstances[i] = go;
                phaseRenderers[i] = go.GetComponentsInChildren<MeshRenderer>(true);
            }
        }

        // Activates only phase <paramref name="index"/> and points tintedRenderers at its
        // renderers so the existing material/tint helpers drive the active phase.
        private void ShowPhase(int index)
        {
            if (phaseInstances == null) return;
            index = Mathf.Clamp(index, 0, phaseInstances.Length - 1);

            for (int i = 0; i < phaseInstances.Length; i++)
                if (phaseInstances[i] != null)
                    phaseInstances[i].SetActive(i == index);

            activePhaseIndex = index;
            tintedRenderers = phaseRenderers[index];
        }

        // Maps current normalized progress to a phase index (0..PhaseCount-1).
        private int PhaseIndexFromProgress()
        {
            int n = effectivePhaseSet != null ? effectivePhaseSet.PhaseCount : 1;
            if (n <= 1) return 0;
            float p = ComputeProgressNormalized();
            return Mathf.Clamp(Mathf.FloorToInt(p * n), 0, n - 1);
        }

        // Stage index 0..stageCount-1 based on elapsed server time PLUS any accumulated
        // time from previous Constructing runs (resume support).
        // Returned Y-scale is (stageIndex + 1) / stageCount, so stage 0 = 1/4,
        // stage 1 = 2/4, ..., stage stageCount-1 = 4/4 = full height.
        private float ComputeConstructingYScale()
        {
            float bt = buildTime.Value;
            if (bt <= 0f || stageCount <= 0) return 1f;

            float currentRunElapsed = (float)NetworkManager.Singleton.ServerTime.Time
                                      - constructionStartServerTime.Value;
            float total = currentRunElapsed + accumulatedConstructionTime.Value;

            float perStage = bt / stageCount;
            int stageIndex = Mathf.Clamp(
                Mathf.FloorToInt(total / perStage),
                0, stageCount - 1);

            return (stageIndex + 1f) / stageCount;
        }

        // While Paused, only the accumulated time matters (no current run is in flight).
        private float ComputePausedYScale()
        {
            float bt = buildTime.Value;
            if (bt <= 0f || stageCount <= 0) return 1f;

            float total = accumulatedConstructionTime.Value;
            float perStage = bt / stageCount;
            int stageIndex = Mathf.Clamp(
                Mathf.FloorToInt(total / perStage),
                0, stageCount - 1);

            return (stageIndex + 1f) / stageCount;
        }

        private void ApplyYScale(float y)
        {
            if (scaleTarget == null) return;
            Vector3 s = scaleTarget.localScale;
            scaleTarget.localScale = new Vector3(s.x, y, s.z);
        }

        // ----- Material handling ------------------------------------------

        private void SwapMaterial(Material mat)
        {
            if (mat == null || tintedRenderers == null) return;
            foreach (var rend in tintedRenderers)
            {
                if (rend == null) continue;
                rend.sharedMaterial = mat;
            }
        }

        // Reused per-instance to avoid GC. Lazy because Unity disallows
        // construction in field initializers.
        private MaterialPropertyBlock colorPropertyBlock;
        private static readonly int ColorPropertyId = Shader.PropertyToID("_Color");
        private static readonly int BaseColorPropertyId = Shader.PropertyToID("_BaseColor");

        private void ApplyOwnerTint()
        {
            if (tintedRenderers == null) return;

            Color c = PlayerColors.Get(ownerSlot.Value);
            c.a = 1f;

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

            foreach (var rend in tintedRenderers)
            {
                if (rend == null) continue;
                rend.SetPropertyBlock(colorPropertyBlock);
            }
        }
    }
}