using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using TD.Core;

namespace TD.Levels.Editor
{
    /// <summary>
    /// Editor-only bake pipeline that runs the seven-phase algorithm against a
    /// <see cref="LevelAuthoring"/> in the active scene and writes the result into the
    /// <see cref="LevelData"/> ScriptableObject pointed to by <c>authoring.targetAsset</c>.
    /// </summary>
    /// <remarks>
    /// Phases:
    ///   1. Volume Discovery (scoped + orphan scan, canonical sort)
    ///   2. Pre-Validation (cheap checks, all errors collected before aborting)
    ///   3. Tile Rasterization (per-volume tile sets, per-zone unions, per-goal sets)
    ///   4. Grid Composition (PlacementGrid + WalkabilityGrid; "Invalid wins")
    ///   5. Spatial Validation (overlap, adjacency, connectivity)
    ///   6. Output Assembly (in-memory LevelData; thumbnail; hash; flat grids; nested data)
    ///   7. Commit (field-by-field copy onto target asset, save, refresh)
    ///
    /// Failed bakes (any hard error in any phase) do NOT modify the existing target asset on
    /// disk. Failure state lives only in <see cref="LevelAuthoringEditorState"/>'s transient
    /// flag, not on the asset itself.
    /// </remarks>
    public static class LevelBakePipeline
    {
        // -------------------------------------------------------------------
        // Public entry point
        // -------------------------------------------------------------------

        /// <summary>
        /// Runs the bake. Returns true on success (with or without warnings); false on hard error.
        /// On failure, <paramref name="authoring"/>.targetAsset is left untouched on disk.
        /// </summary>
        public static bool Bake(LevelAuthoring authoring)
        {
            var report = new BakeReport();

            if (authoring == null)
            {
                Debug.LogError("[LevelBake] Cannot bake: LevelAuthoring is null.");
                return false;
            }

            // -- Phase 1: Volume Discovery -------------------------------
            var ctx = new BakeContext { Authoring = authoring };
            Phase1_Discover(ctx, report);
            if (report.HasErrors) return Abort(report);

            // -- Phase 2: Pre-Validation ---------------------------------
            Phase2_PreValidate(ctx, report);
            if (report.HasErrors) return Abort(report);

            // -- Phase 3: Tile Rasterization -----------------------------
            Phase3_Rasterize(ctx, report);
            if (report.HasErrors) return Abort(report);

            // -- Phase 4: Grid Composition -------------------------------
            Phase4_ComposeGrids(ctx, report);
            if (report.HasErrors) return Abort(report);

            // -- Phase 5: Spatial Validation -----------------------------
            Phase5_SpatialValidate(ctx, report);
            if (report.HasErrors) return Abort(report);

            // -- Phase 6: Output Assembly --------------------------------
            Phase6_AssembleOutput(ctx, report);
            if (report.HasErrors) return Abort(report);

            // -- Phase 7: Commit -----------------------------------------
            Phase7_Commit(ctx, report);
            if (report.HasErrors) return Abort(report);

            // Success path: log warnings (if any) and a summary.
            LevelAuthoringEditorState.HasUncommittedFailure = false;

            BakeOutcome outcome = report.WarningCount > 0
                ? BakeOutcome.SuccessWithWarnings
                : BakeOutcome.Success;

            Debug.Log($"[LevelBake] {outcome} — '{ctx.Authoring.mapName}' " +
                      $"({ctx.PlayerZoneVolumes.Count} player zones, {ctx.SpawnerVolumes.Count} spawners, " +
                      $"{ctx.LeakExitVolumes.Count} leak exits, {ctx.GoalVolumes.Count} goals, " +
                      $"{ctx.MapAreaVolumes.Count} map areas; " +
                      $"grid {ctx.GridSize.x}×{ctx.GridSize.y}; {report.WarningCount} warnings)");

            if (report.WarningCount > 0)
            {
                Debug.LogWarning($"[LevelBake] Bake produced {report.WarningCount} warning(s):\n{report.Format()}");
            }

            return true;
        }

        // Helper: log full report as error and set the transient failure flag.
        private static bool Abort(BakeReport report)
        {
            LevelAuthoringEditorState.HasUncommittedFailure = true;
            Debug.LogError($"[LevelBake] FAILED. Existing target asset (if any) was not modified.\n{report.Format()}");
            return false;
        }

        // -------------------------------------------------------------------
        // PHASE 1 — Volume Discovery
        // -------------------------------------------------------------------

        private static void Phase1_Discover(BakeContext ctx, BakeReport report)
        {
            // Defensive — already null-checked at entry, but Phase 1 is also responsible per the spec.
            if (ctx.Authoring == null)
            {
                report.Error("P1", "LevelAuthoring reference is null.");
                return;
            }

            var rootTransform = ctx.Authoring.transform;

            // Scoped scan: only volumes parented under _LevelAuthoring's transform are part of the bake.
            ctx.PlayerZoneVolumes = rootTransform.GetComponentsInChildren<PlayerZoneVolume>(includeInactive: false).ToList();
            ctx.SpawnerVolumes = rootTransform.GetComponentsInChildren<SpawnerVolume>(includeInactive: false).ToList();
            ctx.LeakExitVolumes = rootTransform.GetComponentsInChildren<LeakExitVolume>(includeInactive: false).ToList();
            ctx.GoalVolumes = rootTransform.GetComponentsInChildren<GoalVolume>(includeInactive: false).ToList();
            ctx.MapAreaVolumes = rootTransform.GetComponentsInChildren<MapAreaVolume>(includeInactive: false).ToList();

            ctx.AllVolumes = new List<VolumeBase>();
            ctx.AllVolumes.AddRange(ctx.PlayerZoneVolumes);
            ctx.AllVolumes.AddRange(ctx.SpawnerVolumes);
            ctx.AllVolumes.AddRange(ctx.LeakExitVolumes);
            ctx.AllVolumes.AddRange(ctx.GoalVolumes);
            ctx.AllVolumes.AddRange(ctx.MapAreaVolumes);

            // Full-scene scan: catch volumes that exist but aren't parented under _LevelAuthoring.
            var orphanCandidates = UnityEngine.Object.FindObjectsByType<VolumeBase>(FindObjectsInactive.Exclude);
            var includedSet = new HashSet<VolumeBase>(ctx.AllVolumes);
            foreach (var v in orphanCandidates)
            {
                if (v == null || includedSet.Contains(v)) continue;
                Debug.LogWarning($"[LevelAuthoring] Orphaned volume excluded from bake: '{v.name}' " +
                                 $"({v.GetType().Name}) is not parented under '{rootTransform.name}' and was skipped.");
            }

            // Canonical sort by hierarchy path with sibling-index disambiguation. Used for both the
            // hash input ordering and stable iteration in subsequent phases.
            ctx.AllVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
            ctx.PlayerZoneVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
            ctx.SpawnerVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
            ctx.LeakExitVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
            ctx.GoalVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
            ctx.MapAreaVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
        }

        // Builds the canonical path string for a transform, relative to the root, with sibling
        // indices appended to each segment. Example: "Spawners/Spawner[3]/Inner[0]".
        // Used for both the canonical sort and the hash input.
        private static string CanonicalPath(Component component, Transform root)
        {
            if (component == null) return "<null>";
            var t = component.transform;
            var segments = new List<string>();

            while (t != null && t != root)
            {
                int siblingIndex = t.GetSiblingIndex();
                segments.Add($"{t.name}[{siblingIndex}]");
                t = t.parent;
            }

            segments.Reverse();
            return string.Join("/", segments);
        }

        // -------------------------------------------------------------------
        // PHASE 2 — Pre-Validation (cheap checks, no tile rasterization yet)
        // -------------------------------------------------------------------

        private static readonly HashSet<int> ValidPlayerCounts = new HashSet<int> { 1, 2, 3, 4, 5, 9 };

        private static void Phase2_PreValidate(BakeContext ctx, BakeReport report)
        {
            var auth = ctx.Authoring;

            // P2-13: targetAsset is non-null
            if (auth.targetAsset == null)
            {
                report.Error("P2-13", "LevelAuthoring.targetAsset is not set. Assign a LevelData ScriptableObject before baking.");
            }

            // P2-14: mapName is non-empty
            if (string.IsNullOrWhiteSpace(auth.mapName))
            {
                report.Error("P2-14", "Map name is empty.");
            }

            // P2-15: playerCount is in {1, 2, 3, 4, 5, 9}
            if (!ValidPlayerCounts.Contains(auth.playerCount))
            {
                report.Error("P2-15", $"Player count must be one of {{1, 2, 3, 4, 5, 9}}; got {auth.playerCount}.");
            }

            // P2-2.5: expectedGoalCount >= 1
            if (auth.expectedGoalCount < 1)
            {
                report.Error("P2-2.5", $"expectedGoalCount must be ≥ 1; got {auth.expectedGoalCount}.");
            }

            // P2-2: GoalVolume count equals expectedGoalCount
            if (ctx.GoalVolumes.Count != auth.expectedGoalCount)
            {
                report.Error("P2-2", $"Found {ctx.GoalVolumes.Count} GoalVolume(s); LevelAuthoring expects {auth.expectedGoalCount}.");
            }

            // P2-22: at least one MapAreaVolume is present.
            // The map area is required (not inferred) so that gameplay bounds stay decoupled from
            // visual geometry. See "Lessons Learned" — inferring playable bounds from terrain
            // mesh extent was deliberately rejected.
            if (ctx.MapAreaVolumes.Count == 0)
            {
                report.Error("P2-22", "No MapAreaVolume found. Every map must have at least one " +
                                      "MapAreaVolume defining the playable bounds (where the builder " +
                                      "can move and the camera can pan).");
            }

            // P2-16: author non-empty (soft warning)
            if (string.IsNullOrWhiteSpace(auth.author))
            {
                report.Warning("P2-16", "Author field is empty.");
            }

            // P2-17: mapDescription non-empty (soft warning)
            if (string.IsNullOrWhiteSpace(auth.mapDescription))
            {
                report.Warning("P2-17", "Map description is empty.");
            }

            // -- Per-volume checks --

            // Build the set of declared player zones (owners present in the scene).
            var declaredOwners = new HashSet<PlayerSlot>();
            foreach (var z in ctx.PlayerZoneVolumes) declaredOwners.Add(z.owner);
            ctx.DeclaredOwners = declaredOwners;

            // Build the expected set of owners based on playerCount (Player1..PlayerN).
            var expectedOwners = new HashSet<PlayerSlot>();
            if (ValidPlayerCounts.Contains(auth.playerCount))
            {
                for (int i = 1; i <= auth.playerCount; i++) expectedOwners.Add((PlayerSlot)(byte)i);
            }
            ctx.ExpectedOwners = expectedOwners;

            // P2-1: every expected PlayerSlot has at least one PlayerZoneVolume
            foreach (var slot in expectedOwners)
            {
                if (!declaredOwners.Contains(slot))
                {
                    report.Error("P2-1", $"No PlayerZoneVolume found for {slot}.");
                }
            }

            // P2-6: no volume declares a PlayerSlot exceeding playerCount
            foreach (var z in ctx.PlayerZoneVolumes)
            {
                if (z.owner != PlayerSlot.None && (byte)z.owner > auth.playerCount)
                {
                    report.Error("P2-6", $"PlayerZoneVolume '{z.name}' owner is {z.owner} but playerCount is {auth.playerCount}.");
                }
            }
            foreach (var s in ctx.SpawnerVolumes)
            {
                if (s.owner != PlayerSlot.None && (byte)s.owner > auth.playerCount)
                {
                    report.Error("P2-6", $"SpawnerVolume '{s.name}' owner is {s.owner} but playerCount is {auth.playerCount}.");
                }
            }
            foreach (var l in ctx.LeakExitVolumes)
            {
                if (l.sourceZone != PlayerSlot.None && (byte)l.sourceZone > auth.playerCount)
                {
                    report.Error("P2-6", $"LeakExitVolume '{l.name}' sourceZone is {l.sourceZone} but playerCount is {auth.playerCount}.");
                }
                if (l.target != PlayerSlot.None && (byte)l.target > auth.playerCount)
                {
                    report.Error("P2-6", $"LeakExitVolume '{l.name}' target is {l.target} but playerCount is {auth.playerCount}.");
                }
            }

            // P2-3: every spawner.owner matches an existing player zone
            foreach (var s in ctx.SpawnerVolumes)
            {
                if (!declaredOwners.Contains(s.owner))
                {
                    report.Error("P2-3", $"SpawnerVolume '{s.name}' owner is {s.owner}, which has no PlayerZoneVolume.");
                }
            }

            // P2-4: every leak exit sourceZone matches an existing player zone
            // P2-5: every leak exit target matches an existing player zone
            foreach (var l in ctx.LeakExitVolumes)
            {
                if (!declaredOwners.Contains(l.sourceZone))
                {
                    report.Error("P2-4", $"LeakExitVolume '{l.name}' sourceZone is {l.sourceZone}, which has no PlayerZoneVolume.");
                }
                if (!declaredOwners.Contains(l.target))
                {
                    report.Error("P2-5", $"LeakExitVolume '{l.name}' target is {l.target}, which has no PlayerZoneVolume.");
                }
            }

            // P2-7: spawner IDs unique within their zone
            // P2-8: spawner IDs contiguous from 0 (soft warning)
            var spawnersByOwner = ctx.SpawnerVolumes
                .GroupBy(s => s.owner)
                .ToDictionary(g => g.Key, g => g.ToList());
            foreach (var kv in spawnersByOwner)
            {
                var ids = kv.Value.Select(s => s.spawnerIdInZone).ToList();
                var idSet = new HashSet<int>();
                foreach (var id in ids)
                {
                    if (!idSet.Add(id))
                    {
                        report.Error("P2-7", $"Spawner zone {kv.Key} has duplicate spawnerIdInZone={id}.");
                    }
                }
                ids.Sort();
                for (int i = 0; i < ids.Count; i++)
                {
                    if (ids[i] != i)
                    {
                        report.Warning("P2-8", $"Spawner zone {kv.Key} has non-contiguous IDs (sorted: [{string.Join(",", ids)}]). Expected 0..{ids.Count - 1}.");
                        break;
                    }
                }
            }

            // P2-11: leak weights for each source zone sum to non-zero
            var leaksBySource = ctx.LeakExitVolumes
                .GroupBy(l => l.sourceZone)
                .ToDictionary(g => g.Key, g => g.ToList());
            foreach (var kv in leaksBySource)
            {
                float total = 0f;
                foreach (var l in kv.Value) total += l.weight;
                if (total <= 0f)
                {
                    report.Error("P2-11", $"Leak exits for source zone {kv.Key} have non-positive total weight ({total}).");
                }
            }

            // P2-18: every volume's BoxCollider.bounds vertically contains Y=0
            foreach (var v in ctx.AllVolumes)
            {
                var col = v.GetComponent<BoxCollider>();
                if (col == null)
                {
                    report.Error("P2-18", $"Volume '{v.name}' has no BoxCollider.");
                    continue;
                }
                var b = col.bounds;
                if (b.min.y > 0f || b.max.y < 0f)
                {
                    report.Error("P2-18", $"Volume '{v.name}' bounds do not vertically contain Y=0 (Y range: {b.min.y:F2}..{b.max.y:F2}).");
                }
            }

            // P2-19: every volume's transform rotation is zero
            foreach (var v in ctx.AllVolumes)
            {
                Vector3 e = v.transform.eulerAngles;
                // Normalize to (-180..180] for comparison.
                float dx = NormalizeAngle(e.x);
                float dy = NormalizeAngle(e.y);
                float dz = NormalizeAngle(e.z);
                const float tol = 0.01f;
                if (Mathf.Abs(dx) > tol || Mathf.Abs(dy) > tol || Mathf.Abs(dz) > tol)
                {
                    report.Error("P2-19", $"Volume '{v.name}' has non-zero rotation ({e}). Volumes must be axis-aligned.");
                }
            }

            // P2-21: every volume's transform.localScale equals (1, 1, 1) (soft warning)
            foreach (var v in ctx.AllVolumes)
            {
                Vector3 s = v.transform.localScale;
                const float tol = 0.0001f;
                if (Mathf.Abs(s.x - 1f) > tol || Mathf.Abs(s.y - 1f) > tol || Mathf.Abs(s.z - 1f) > tol)
                {
                    report.Warning("P2-21", $"Volume '{v.name}' has non-unit transform.localScale ({s}). Use BoxCollider.Size for sizing instead of Transform.Scale to avoid tile-snapping issues.");
                }
            }

            // P2-20 is checked in Phase 3 (it requires tile-rasterized aggregate bounds).
        }

        private static float NormalizeAngle(float a)
        {
            a = a % 360f;
            if (a > 180f) a -= 360f;
            else if (a < -180f) a += 360f;
            return a;
        }

        // -------------------------------------------------------------------
        // PHASE 3 — Tile Rasterization
        // -------------------------------------------------------------------

        private static void Phase3_Rasterize(BakeContext ctx, BakeReport report)
        {
            ctx.PerVolumeTiles = new Dictionary<VolumeBase, HashSet<Vector2Int>>();
            ctx.PerOwnerZoneTiles = new Dictionary<PlayerSlot, HashSet<Vector2Int>>();
            ctx.PerGoalTiles = new List<HashSet<Vector2Int>>();
            ctx.MapAreaTiles = new HashSet<Vector2Int>();

            bool initializedAggregate = false;
            int aggMinX = 0, aggMinY = 0, aggMaxX = 0, aggMaxY = 0;

            foreach (var v in ctx.AllVolumes)
            {
                var col = v.GetComponent<BoxCollider>();
                if (col == null) continue; // Already errored in P2-18

                var tiles = new HashSet<Vector2Int>();
                VolumeBase.RasterizeBoundsToTiles(col.bounds, t => tiles.Add(t));

                if (tiles.Count == 0)
                {
                    // Defensive backstop — most causes are caught by P2-18 (Y=0 containment),
                    // but a degenerate-size collider (e.g. Size = 0) produces an empty tile set
                    // that the spec also rejects.
                    report.Error("P3", $"Volume '{v.name}' rasterized to zero tiles. " +
                                       "Check that the BoxCollider has non-zero Size and its bounds vertically intersect Y=0.");
                    continue;
                }

                ctx.PerVolumeTiles[v] = tiles;

                // Update aggregate map bounds.
                foreach (var t in tiles)
                {
                    if (!initializedAggregate)
                    {
                        aggMinX = aggMaxX = t.x;
                        aggMinY = aggMaxY = t.y;
                        initializedAggregate = true;
                    }
                    else
                    {
                        if (t.x < aggMinX) aggMinX = t.x;
                        if (t.x > aggMaxX) aggMaxX = t.x;
                        if (t.y < aggMinY) aggMinY = t.y;
                        if (t.y > aggMaxY) aggMaxY = t.y;
                    }
                }

                // Aggregate per-owner zone tile sets.
                if (v is PlayerZoneVolume zone)
                {
                    if (!ctx.PerOwnerZoneTiles.TryGetValue(zone.owner, out var ownerSet))
                    {
                        ownerSet = new HashSet<Vector2Int>();
                        ctx.PerOwnerZoneTiles[zone.owner] = ownerSet;
                    }
                    foreach (var t in tiles) ownerSet.Add(t);
                }

                // Goals: one tile set per goal (NOT unioned).
                if (v is GoalVolume)
                {
                    ctx.PerGoalTiles.Add(tiles);
                }

                // Map area: union all MapAreaVolume tiles into a single set. The map area is the
                // union of all MapAreaVolumes, so a tile is "in the map" if ANY MapAreaVolume
                // covers it.
                if (v is MapAreaVolume)
                {
                    foreach (var t in tiles) ctx.MapAreaTiles.Add(t);
                }
            }

            if (!initializedAggregate)
            {
                report.Error("P3", "No volumes rasterized to any tiles. The map is empty.");
                return;
            }

            ctx.MapMinTile = new Vector2Int(aggMinX, aggMinY);
            ctx.MapMaxTile = new Vector2Int(aggMaxX, aggMaxY);

            // P2-20: map's tile bounding rect's min corner is (0, 0). Soft warning.
            // (Checked here because it requires the rasterized aggregate bounds.)
            if (ctx.MapMinTile.x != 0 || ctx.MapMinTile.y != 0)
            {
                report.Warning("P2-20", $"Map's southwest corner is at tile {ctx.MapMinTile}, not (0, 0). " +
                                        "Convention is for tile (0,0) to be the southwest corner of the map.");
            }
        }

        // -------------------------------------------------------------------
        // PHASE 4 — Grid Composition
        // -------------------------------------------------------------------

        private static void Phase4_ComposeGrids(BakeContext ctx, BakeReport report)
        {
            ctx.GridOriginTile = ctx.MapMinTile;
            ctx.GridSize = new Vector2Int(
                ctx.MapMaxTile.x - ctx.MapMinTile.x + 1,
                ctx.MapMaxTile.y - ctx.MapMinTile.y + 1);

            int width = ctx.GridSize.x;
            int height = ctx.GridSize.y;

            // 2D arrays during composition; flattened to 1D in Phase 6.
            ctx.PlacementGrid2D = new PlacementState[width, height];   // defaults to Outside (0)
            ctx.WalkabilityGrid2D = new bool[width, height];           // defaults to false
            ctx.MapAreaGrid2D = new bool[width, height];               // defaults to false

            // Map-area composition: a tile is "in map" iff any MapAreaVolume covers it. We have
            // the union pre-computed in ctx.MapAreaTiles from Phase 3.
            foreach (var t in ctx.MapAreaTiles)
            {
                int gx = t.x - ctx.GridOriginTile.x;
                int gy = t.y - ctx.GridOriginTile.y;
                ctx.MapAreaGrid2D[gx, gy] = true;
            }

            // Volume-iterating algorithm: O(total tile-coverage) rather than O(grid * volumes).
            foreach (var v in ctx.AllVolumes)
            {
                if (!ctx.PerVolumeTiles.TryGetValue(v, out var tiles)) continue;

                // MapAreaVolume contributes ONLY to the map-area grid (handled above) — not to
                // walkability or placement. Buffer tiles (in-map but not gameplay) must remain
                // non-walkable so enemies cannot path through them.
                if (v is MapAreaVolume) continue;

                bool isInvalid = IsInvalidValidity(v);
                bool isPlayerZone = v is PlayerZoneVolume;

                foreach (var tile in tiles)
                {
                    int gx = tile.x - ctx.GridOriginTile.x;
                    int gy = tile.y - ctx.GridOriginTile.y;
                    // bounds always within [0..size) by construction (aggregate min/max inclusive).

                    // All volume tiles are walkable initially.
                    ctx.WalkabilityGrid2D[gx, gy] = true;

                    // Placement composition: "Invalid wins". Once a tile is Restricted, it stays Restricted.
                    if (isInvalid)
                    {
                        ctx.PlacementGrid2D[gx, gy] = PlacementState.Restricted;
                    }
                    else if (isPlayerZone && ctx.PlacementGrid2D[gx, gy] != PlacementState.Restricted)
                    {
                        ctx.PlacementGrid2D[gx, gy] = PlacementState.Buildable;
                    }
                }
            }
        }

        private static bool IsInvalidValidity(VolumeBase v)
        {
            // PlayerZoneVolume.placementValidity defaults to Allowed; others default to Invalid.
            // We check the field on each subclass.
            switch (v)
            {
                case PlayerZoneVolume pz: return pz.placementValidity == PlacementValidity.Invalid;
                case SpawnerVolume sv: return sv.placementValidity == PlacementValidity.Invalid;
                case LeakExitVolume lv: return lv.placementValidity == PlacementValidity.Invalid;
                case GoalVolume gv: return gv.placementValidity == PlacementValidity.Invalid;
                default: return false;
            }
        }

        // -------------------------------------------------------------------
        // PHASE 5 — Spatial Validation
        // -------------------------------------------------------------------

        private static void Phase5_SpatialValidate(BakeContext ctx, BakeReport report)
        {
            // P5-1: no PlayerZoneVolumes with different owners overlap.
            // For each tile in any zone, track which owners cover it.
            var tileToOwners = new Dictionary<Vector2Int, HashSet<PlayerSlot>>();
            foreach (var z in ctx.PlayerZoneVolumes)
            {
                if (!ctx.PerVolumeTiles.TryGetValue(z, out var tiles)) continue;
                foreach (var t in tiles)
                {
                    if (!tileToOwners.TryGetValue(t, out var owners))
                    {
                        owners = new HashSet<PlayerSlot>();
                        tileToOwners[t] = owners;
                    }
                    owners.Add(z.owner);
                }
            }
            foreach (var kv in tileToOwners)
            {
                if (kv.Value.Count > 1)
                {
                    var ownersList = string.Join(", ", kv.Value);
                    report.Error("P5-1", $"Tile {kv.Key} is covered by multiple PlayerZoneVolumes with different owners: {ownersList}.");
                }
            }

            // Build per-goal tile lookup. P5-2 and P5-3 use this.
            var goalTileToIdx = new Dictionary<Vector2Int, int>();
            for (int g = 0; g < ctx.PerGoalTiles.Count; g++)
            {
                foreach (var t in ctx.PerGoalTiles[g])
                {
                    if (goalTileToIdx.TryGetValue(t, out int otherIdx))
                    {
                        // P5-3
                        if (otherIdx != g)
                        {
                            report.Error("P5-3", $"Tile {t} is covered by multiple GoalVolumes (goal #{otherIdx} and goal #{g}).");
                        }
                    }
                    else
                    {
                        goalTileToIdx[t] = g;
                    }
                }
            }

            // P5-2: no PlayerZoneVolume overlaps a GoalVolume
            foreach (var t in tileToOwners.Keys)
            {
                if (goalTileToIdx.ContainsKey(t))
                {
                    report.Error("P5-2", $"Tile {t} is covered by both a PlayerZoneVolume and a GoalVolume.");
                }
            }

            // Build a unified set of all walkable tiles (for BFS connectivity).
            var walkableSet = new HashSet<Vector2Int>();
            for (int x = 0; x < ctx.GridSize.x; x++)
            {
                for (int y = 0; y < ctx.GridSize.y; y++)
                {
                    if (ctx.WalkabilityGrid2D[x, y])
                    {
                        walkableSet.Add(new Vector2Int(x + ctx.GridOriginTile.x, y + ctx.GridOriginTile.y));
                    }
                }
            }

            // P5-10: walkability grid is non-empty
            if (walkableSet.Count == 0)
            {
                report.Error("P5-10", "Walkability grid is empty — no tiles are walkable.");
                return;
            }

            // Build the set of "exit" tiles: any leak exit tile, OR any goal tile.
            var exitTiles = new HashSet<Vector2Int>();
            foreach (var l in ctx.LeakExitVolumes)
            {
                if (!ctx.PerVolumeTiles.TryGetValue(l, out var tiles)) continue;
                foreach (var t in tiles) exitTiles.Add(t);
            }
            foreach (var t in goalTileToIdx.Keys) exitTiles.Add(t);

            // P5-4: every spawner's tiles reach an exit via walkability BFS.
            // Use a single shared queue to avoid allocations across spawners.
            var bfsQueue = new Queue<Vector2Int>();
            var bfsVisited = new HashSet<Vector2Int>();
            foreach (var s in ctx.SpawnerVolumes)
            {
                if (!ctx.PerVolumeTiles.TryGetValue(s, out var spawnerTiles) || spawnerTiles.Count == 0) continue;

                bfsQueue.Clear();
                bfsVisited.Clear();
                foreach (var t in spawnerTiles)
                {
                    bfsQueue.Enqueue(t);
                    bfsVisited.Add(t);
                }

                bool reachedExit = false;
                while (bfsQueue.Count > 0)
                {
                    var t = bfsQueue.Dequeue();
                    if (exitTiles.Contains(t)) { reachedExit = true; break; }

                    // 8-connected with corner-cut prevention — must match the
                    // runtime PathfindingService / TowerPlacementManager rules.
                    foreach (var n in GridCoordinates.GetNeighbors8(t))
                    {
                        if (bfsVisited.Contains(n)) continue;
                        if (!walkableSet.Contains(n)) continue;

                        if (GridCoordinates.IsDiagonal(t, n))
                        {
                            GridCoordinates.GetCornerShoulders(t, n,
                                out var shoulderA, out var shoulderB);
                            if (!walkableSet.Contains(shoulderA) || !walkableSet.Contains(shoulderB))
                                continue;
                        }

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

                if (!reachedExit)
                {
                    report.Error("P5-4", $"SpawnerVolume '{s.name}' (owner {s.owner}, id {s.spawnerIdInZone}) cannot reach any leak exit or goal via walkability.");
                }
            }

            // P5-5: spawner's tiles inside its declared owner's zone (soft warning).
            foreach (var s in ctx.SpawnerVolumes)
            {
                if (!ctx.PerVolumeTiles.TryGetValue(s, out var spawnerTiles)) continue;
                if (!ctx.PerOwnerZoneTiles.TryGetValue(s.owner, out var ownerZoneTiles))
                {
                    // owner not declared — already errored in P2-3
                    continue;
                }
                foreach (var t in spawnerTiles)
                {
                    if (!ownerZoneTiles.Contains(t))
                    {
                        report.Warning("P5-5", $"SpawnerVolume '{s.name}' (owner {s.owner}) has tile {t} outside owner's PlayerZoneVolume coverage.");
                        break; // only report once per spawner
                    }
                }
            }

            // P5-6: every leak exit's tiles 4-adjacent to its target zone's tiles.
            // P5-7: every leak exit's tiles 4-adjacent to its source zone's tiles.
            foreach (var l in ctx.LeakExitVolumes)
            {
                if (!ctx.PerVolumeTiles.TryGetValue(l, out var leakTiles)) continue;

                bool hasSourceAdj = false;
                bool hasTargetAdj = false;

                ctx.PerOwnerZoneTiles.TryGetValue(l.sourceZone, out var sourceTiles);
                ctx.PerOwnerZoneTiles.TryGetValue(l.target, out var targetTiles);

                if (sourceTiles != null && targetTiles != null)
                {
                    foreach (var t in leakTiles)
                    {
                        foreach (var n in GridCoordinates.GetNeighbors(t))
                        {
                            if (!hasSourceAdj && sourceTiles.Contains(n)) hasSourceAdj = true;
                            if (!hasTargetAdj && targetTiles.Contains(n)) hasTargetAdj = true;
                            if (hasSourceAdj && hasTargetAdj) break;
                        }
                        if (hasSourceAdj && hasTargetAdj) break;
                    }
                }

                if (sourceTiles != null && !hasSourceAdj)
                {
                    report.Error("P5-7", $"LeakExitVolume '{l.name}' is not 4-adjacent to its sourceZone {l.sourceZone}.");
                }
                if (targetTiles != null && !hasTargetAdj)
                {
                    report.Error("P5-6", $"LeakExitVolume '{l.name}' is not 4-adjacent to its target {l.target}.");
                }
            }

            // P5-9: at least one player zone is goal-adjacent.
            // P5-8: every player zone has outgoing leak OR is goal-adjacent.
            var goalAdjacentZones = new HashSet<PlayerSlot>();
            var allGoalTiles = new HashSet<Vector2Int>(goalTileToIdx.Keys);

            foreach (var kv in ctx.PerOwnerZoneTiles)
            {
                bool isGoalAdjacent = false;
                foreach (var t in kv.Value)
                {
                    foreach (var n in GridCoordinates.GetNeighbors(t))
                    {
                        if (allGoalTiles.Contains(n)) { isGoalAdjacent = true; break; }
                    }
                    if (isGoalAdjacent) break;
                }
                if (isGoalAdjacent) goalAdjacentZones.Add(kv.Key);
            }

            ctx.GoalAdjacentZones = goalAdjacentZones;

            if (goalAdjacentZones.Count == 0)
            {
                report.Error("P5-9", "No player zone is goal-adjacent. At least one zone must be 4-adjacent to a GoalVolume.");
            }

            // P5-8: zones with neither outgoing leak nor goal adjacency.
            var zonesWithLeaks = new HashSet<PlayerSlot>();
            foreach (var l in ctx.LeakExitVolumes) zonesWithLeaks.Add(l.sourceZone);

            foreach (var owner in ctx.DeclaredOwners)
            {
                bool hasLeak = zonesWithLeaks.Contains(owner);
                bool isGoalAdj = goalAdjacentZones.Contains(owner);
                if (!hasLeak && !isGoalAdj)
                {
                    report.Error("P5-8", $"Player zone {owner} has no outgoing leak exit AND is not goal-adjacent. " +
                                         "Every zone must either lead somewhere or be a final defender.");
                }
            }

            // P5-11: no isolated walkable regions (soft warning).
            // Connected-component count over the walkable set; warn if more than one component.
            int components = CountWalkableComponents(walkableSet);
            if (components > 1)
            {
                report.Warning("P5-11", $"Walkability grid has {components} disconnected regions. " +
                                        "Some areas of the map are unreachable from others.");
            }

            // P5-12: every gameplay volume's tiles must be a subset of the map area.
            // Coverage rule from the design spec — gameplay can't poke outside the playable map.
            // Skipped if no MapAreaVolume exists (P2-22 already errored; this would just spam).
            if (ctx.MapAreaTiles.Count > 0)
            {
                ValidateGameplayContainedInMapArea(ctx, report);
            }
        }

        // P5-12: each gameplay volume (PlayerZone, Spawner, LeakExit, Goal) must have all its
        // tiles inside ctx.MapAreaTiles. Reports one error per offending volume with the count of
        // out-of-map tiles to avoid drowning the report in per-tile errors.
        private static void ValidateGameplayContainedInMapArea(BakeContext ctx, BakeReport report)
        {
            foreach (var v in ctx.AllVolumes)
            {
                if (v is MapAreaVolume) continue; // The map area itself doesn't need to contain itself.
                if (!ctx.PerVolumeTiles.TryGetValue(v, out var tiles)) continue;

                int outsideCount = 0;
                Vector2Int firstOutside = Vector2Int.zero;
                foreach (var t in tiles)
                {
                    if (!ctx.MapAreaTiles.Contains(t))
                    {
                        if (outsideCount == 0) firstOutside = t;
                        outsideCount++;
                    }
                }

                if (outsideCount > 0)
                {
                    report.Error("P5-12",
                        $"{v.GetType().Name} '{v.name}' has {outsideCount} tile(s) outside the map area " +
                        $"(first offender: {firstOutside}). Every gameplay volume must be fully contained " +
                        "within the union of MapAreaVolumes.");
                }
            }
        }

        private static int CountWalkableComponents(HashSet<Vector2Int> walkable)
        {
            var visited = new HashSet<Vector2Int>();
            int components = 0;
            var queue = new Queue<Vector2Int>();

            foreach (var seed in walkable)
            {
                if (visited.Contains(seed)) continue;
                components++;
                queue.Clear();
                queue.Enqueue(seed);
                visited.Add(seed);

                while (queue.Count > 0)
                {
                    var t = queue.Dequeue();
                    // 8-connected with corner-cut prevention — keeps component
                    // counting consistent with pathfinding semantics.
                    foreach (var n in GridCoordinates.GetNeighbors8(t))
                    {
                        if (visited.Contains(n)) continue;
                        if (!walkable.Contains(n)) continue;

                        if (GridCoordinates.IsDiagonal(t, n))
                        {
                            GridCoordinates.GetCornerShoulders(t, n,
                                out var shoulderA, out var shoulderB);
                            if (!walkable.Contains(shoulderA) || !walkable.Contains(shoulderB))
                                continue;
                        }

                        visited.Add(n);
                        queue.Enqueue(n);
                    }
                }
            }
            return components;
        }

        // -------------------------------------------------------------------
        // PHASE 6 — Output Assembly
        // -------------------------------------------------------------------

        private static void Phase6_AssembleOutput(BakeContext ctx, BakeReport report)
        {
            // Build the in-memory LevelData. We populate a fresh instance and then field-by-field
            // copy onto targetAsset in Phase 7 (preserves the asset's GUID).
            var data = ScriptableObject.CreateInstance<LevelData>();

            data.MapName = ctx.Authoring.mapName;
            data.PlayerCount = ctx.Authoring.playerCount;
            data.MapDescription = ctx.Authoring.mapDescription;
            data.Author = ctx.Authoring.author;
            data.GridOriginTile = ctx.GridOriginTile;
            data.GridSize = ctx.GridSize;

            // Flatten 2D grids to 1D row-major arrays.
            int width = ctx.GridSize.x;
            int height = ctx.GridSize.y;
            int total = width * height;

            data.PlacementGrid = new PlacementState[total];
            data.WalkabilityGrid = new bool[total];
            data.OwnerGrid = new PlayerSlot[total]; // defaults to PlayerSlot.None (=0)
            data.MapAreaGrid = new bool[total];     // defaults to false

            for (int x = 0; x < width; x++)
            {
                for (int y = 0; y < height; y++)
                {
                    int idx = y * width + x;
                    data.PlacementGrid[idx] = ctx.PlacementGrid2D[x, y];
                    data.WalkabilityGrid[idx] = ctx.WalkabilityGrid2D[x, y];
                    data.MapAreaGrid[idx] = ctx.MapAreaGrid2D[x, y];
                }
            }

            // Populate OwnerGrid from per-owner zone tile sets.
            foreach (var kv in ctx.PerOwnerZoneTiles)
            {
                foreach (var tile in kv.Value)
                {
                    int gx = tile.x - ctx.GridOriginTile.x;
                    int gy = tile.y - ctx.GridOriginTile.y;
                    if (gx < 0 || gx >= width || gy < 0 || gy >= height) continue;
                    data.OwnerGrid[gy * width + gx] = kv.Key;
                }
            }

            // Assemble PlayerZoneData[] sorted by Owner (PlayerSlot enum value).
            var declaredOrdered = ctx.DeclaredOwners.OrderBy(o => (byte)o).ToList();
            data.PlayerZones = new PlayerZoneData[declaredOrdered.Count];

            for (int i = 0; i < declaredOrdered.Count; i++)
            {
                PlayerSlot owner = declaredOrdered[i];
                var zoneData = new PlayerZoneData { Owner = owner };

                // Spawners for this owner, sorted by SpawnerIdInZone.
                var ownerSpawners = ctx.SpawnerVolumes
                    .Where(s => s.owner == owner)
                    .OrderBy(s => s.spawnerIdInZone)
                    .ToList();

                zoneData.Spawners = new SpawnerData[ownerSpawners.Count];
                for (int j = 0; j < ownerSpawners.Count; j++)
                {
                    var sv = ownerSpawners[j];
                    var col = sv.GetComponent<BoxCollider>();
                    Vector2Int pos = col != null
                        ? GridCoordinates.WorldToGrid(new Vector2(col.bounds.center.x, col.bounds.center.z))
                        : Vector2Int.zero;
                    Vector2Int[] tileArea = ctx.PerVolumeTiles.TryGetValue(sv, out var spawnerTiles)
                        ? spawnerTiles.OrderBy(t => t.y).ThenBy(t => t.x).ToArray()
                        : new Vector2Int[0];

                    zoneData.Spawners[j] = new SpawnerData
                    {
                        SpawnerIdInZone = sv.spawnerIdInZone,
                        TilePosition = pos,
                        TileArea = tileArea,
                        Facing = sv.spawnFacing,
                    };
                }

                // Leak exits FROM this owner's zone, sorted by Target enum value.
                var ownerLeaks = ctx.LeakExitVolumes
                    .Where(l => l.sourceZone == owner)
                    .OrderBy(l => (byte)l.target)
                    .ToList();

                // Normalize weights within this source zone.
                float totalWeight = 0f;
                foreach (var l in ownerLeaks) totalWeight += l.weight;
                // Already validated > 0 in P2-11 if there are any leaks.

                zoneData.LeakExits = new LeakExitData[ownerLeaks.Count];
                for (int j = 0; j < ownerLeaks.Count; j++)
                {
                    var lv = ownerLeaks[j];
                    Vector2Int[] tileArea = ctx.PerVolumeTiles.TryGetValue(lv, out var leakTiles)
                        ? leakTiles.OrderBy(t => t.y).ThenBy(t => t.x).ToArray()
                        : new Vector2Int[0];

                    zoneData.LeakExits[j] = new LeakExitData
                    {
                        Target = lv.target,
                        TileArea = tileArea,
                        NormalizedWeight = totalWeight > 0f ? lv.weight / totalWeight : 0f,
                    };
                }

                data.PlayerZones[i] = zoneData;
            }

            // Assemble GoalData[] sorted by min tile coordinate.
            var goalsList = new List<GoalData>(ctx.PerGoalTiles.Count);
            foreach (var goalTiles in ctx.PerGoalTiles)
            {
                Vector2Int[] tileArea = goalTiles.OrderBy(t => t.y).ThenBy(t => t.x).ToArray();
                goalsList.Add(new GoalData { TileArea = tileArea });
            }
            // Sort by min tile coordinate (lex on min Y, then min X).
            goalsList.Sort((a, b) =>
            {
                Vector2Int aMin = MinTile(a.TileArea);
                Vector2Int bMin = MinTile(b.TileArea);
                int c = aMin.y.CompareTo(bMin.y);
                if (c != 0) return c;
                return aMin.x.CompareTo(bMin.x);
            });
            data.Goals = goalsList.ToArray();

            // Compute authoring hash from canonical input string.
            data.AuthoringHash = ComputeAuthoringHash(ctx);

            // Populate ScenePath from the active scene.
            data.ScenePath = EditorSceneManager.GetActiveScene().path;

            // Bake metadata.
            data.LastBakeTimestamp = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture);
            data.LastBakeOutcome = report.WarningCount > 0 ? BakeOutcome.SuccessWithWarnings : BakeOutcome.Success;
            data.LastBakeWarningCount = report.WarningCount;

            // Render thumbnail (best-effort; failure logs warning, doesn't abort).
            string assetPath = AssetDatabase.GetAssetPath(ctx.Authoring.targetAsset);
            string thumbnailPath = ComputeThumbnailPath(assetPath);
            try
            {
                if (RenderThumbnail(ctx, thumbnailPath))
                {
                    ctx.ThumbnailPath = thumbnailPath;
                }
                else
                {
                    report.Warning("P6", "Thumbnail render did not produce a file. Continuing without thumbnail.");
                }
            }
            catch (Exception e)
            {
                report.Warning("P6", $"Thumbnail render failed: {e.Message}. Continuing without thumbnail.");
            }

            ctx.AssembledData = data;
        }

        private static Vector2Int MinTile(Vector2Int[] tiles)
        {
            if (tiles == null || tiles.Length == 0) return Vector2Int.zero;
            int minX = tiles[0].x, minY = tiles[0].y;
            for (int i = 1; i < tiles.Length; i++)
            {
                if (tiles[i].x < minX) minX = tiles[i].x;
                if (tiles[i].y < minY) minY = tiles[i].y;
            }
            return new Vector2Int(minX, minY);
        }

        // -- Hash computation -------------------------------------------------

        /// <summary>
        /// Computes the authoring hash for a LevelAuthoring's scene state,
        /// suitable for comparing against <see cref="LevelData.AuthoringHash"/>
        /// to detect drift from baked data.
        /// </summary>
        /// <remarks>
        /// Delegates to the same hashing routine the bake itself uses, so equal
        /// hashes mean the scene would bake to equivalent LevelData. Hashes
        /// only Layer 1 authoring inputs (volumes + metadata); visual scene
        /// content (terrain, lighting, decorations) is NOT included.
        ///
        /// Performs its own volume discovery and canonical sort. Does NOT run
        /// pre-validation or rasterization, so it returns a hash even for scenes
        /// that would fail the bake (e.g. missing zones, overlapping volumes).
        /// That's intentional: the play-mode hook needs a hash even when the
        /// scene is broken, so it can report "drift" as the actionable problem
        /// rather than silently differing from the last successful bake.
        /// </remarks>
        public static string ComputeAuthoringHash(LevelAuthoring authoring)
        {
            if (authoring == null)
                return string.Empty;

            // Build a minimal BakeContext with just the fields the hash routine
            // reads: Authoring and AllVolumes. We mirror Phase 1's discovery
            // logic exactly (scoped scan + canonical sort) so the standalone
            // hash is identical to what Phase 1 would produce on the same scene.
            var ctx = new BakeContext { Authoring = authoring };
            var rootTransform = authoring.transform;

            var playerZones = rootTransform.GetComponentsInChildren<PlayerZoneVolume>(includeInactive: false);
            var spawners = rootTransform.GetComponentsInChildren<SpawnerVolume>(includeInactive: false);
            var leakExits = rootTransform.GetComponentsInChildren<LeakExitVolume>(includeInactive: false);
            var goals = rootTransform.GetComponentsInChildren<GoalVolume>(includeInactive: false);
            var mapAreas = rootTransform.GetComponentsInChildren<MapAreaVolume>(includeInactive: false);

            ctx.AllVolumes = new System.Collections.Generic.List<VolumeBase>(
                playerZones.Length + spawners.Length + leakExits.Length + goals.Length + mapAreas.Length);
            ctx.AllVolumes.AddRange(playerZones);
            ctx.AllVolumes.AddRange(spawners);
            ctx.AllVolumes.AddRange(leakExits);
            ctx.AllVolumes.AddRange(goals);
            ctx.AllVolumes.AddRange(mapAreas);

            // Match Phase 1's canonical sort exactly. The hash routine re-orders
            // by canonical path internally, but sorting AllVolumes here keeps
            // the data flow identical to Phase 1, so any future change to the
            // discovery/sort step is automatically picked up.
            ctx.AllVolumes.Sort((a, b) => string.CompareOrdinal(
                CanonicalPath(a, rootTransform),
                CanonicalPath(b, rootTransform)));

            return ComputeAuthoringHash(ctx);
        }

        private static string ComputeAuthoringHash(BakeContext ctx)
        {
            var sb = new StringBuilder();
            CultureInfo inv = CultureInfo.InvariantCulture;

            // LevelAuthoring metadata block.
            sb.Append("LA|");
            sb.Append("mapName=").Append(ctx.Authoring.mapName ?? "").Append('|');
            sb.Append("playerCount=").Append(ctx.Authoring.playerCount.ToString(inv)).Append('|');
            sb.Append("expectedGoalCount=").Append(ctx.Authoring.expectedGoalCount.ToString(inv)).Append('|');
            sb.Append("mapDescription=").Append(ctx.Authoring.mapDescription ?? "").Append('|');
            sb.Append("author=").Append(ctx.Authoring.author ?? "").Append('\n');

            // Volume blocks, ordered by canonical path.
            var rootTransform = ctx.Authoring.transform;
            var ordered = ctx.AllVolumes
                .Select(v => (volume: v, path: CanonicalPath(v, rootTransform)))
                .OrderBy(x => x.path, StringComparer.Ordinal)
                .ToList();

            foreach (var (v, path) in ordered)
            {
                sb.Append("V|");
                sb.Append("type=").Append(v.GetType().Name).Append('|');
                sb.Append("path=").Append(path).Append('|');

                var col = v.GetComponent<BoxCollider>();
                if (col != null)
                {
                    Bounds b = col.bounds;
                    sb.Append("center=")
                      .Append(b.center.x.ToString("G17", inv)).Append(',')
                      .Append(b.center.y.ToString("G17", inv)).Append(',')
                      .Append(b.center.z.ToString("G17", inv)).Append('|');
                    sb.Append("size=")
                      .Append(b.size.x.ToString("G17", inv)).Append(',')
                      .Append(b.size.y.ToString("G17", inv)).Append(',')
                      .Append(b.size.z.ToString("G17", inv)).Append('|');
                }

                // Per-subtype fields. Enum values by NAME, not numeric value.
                switch (v)
                {
                    case PlayerZoneVolume pz:
                        sb.Append("owner=").Append(pz.owner.ToString()).Append('|');
                        sb.Append("validity=").Append(pz.placementValidity.ToString()).Append('|');
                        break;
                    case SpawnerVolume sv:
                        sb.Append("owner=").Append(sv.owner.ToString()).Append('|');
                        sb.Append("id=").Append(sv.spawnerIdInZone.ToString(inv)).Append('|');
                        sb.Append("facing=").Append(sv.spawnFacing.ToString()).Append('|');
                        sb.Append("validity=").Append(sv.placementValidity.ToString()).Append('|');
                        break;
                    case LeakExitVolume lv:
                        sb.Append("source=").Append(lv.sourceZone.ToString()).Append('|');
                        sb.Append("target=").Append(lv.target.ToString()).Append('|');
                        sb.Append("weight=").Append(lv.weight.ToString("G17", inv)).Append('|');
                        sb.Append("validity=").Append(lv.placementValidity.ToString()).Append('|');
                        break;
                    case GoalVolume gv:
                        sb.Append("validity=").Append(gv.placementValidity.ToString()).Append('|');
                        break;
                }
                sb.Append('\n');
            }

            // SHA256 of UTF-8 bytes.
            byte[] bytes = Encoding.UTF8.GetBytes(sb.ToString());
            using (var sha = SHA256.Create())
            {
                byte[] hash = sha.ComputeHash(bytes);
                var hex = new StringBuilder(hash.Length * 2);
                for (int i = 0; i < hash.Length; i++) hex.Append(hash[i].ToString("x2", inv));
                return hex.ToString();
            }
        }

        // -- Thumbnail rendering ---------------------------------------------

        private static string ComputeThumbnailPath(string assetPath)
        {
            // Asset path looks like "Assets/_Project/Scenes/Levels/MyMap.asset". Strip extension,
            // append "_Thumbnail.png".
            string dir = Path.GetDirectoryName(assetPath) ?? "Assets";
            string nameNoExt = Path.GetFileNameWithoutExtension(assetPath);
            return Path.Combine(dir, nameNoExt + "_Thumbnail.png").Replace("\\", "/");
        }

        private static bool RenderThumbnail(BakeContext ctx, string thumbnailAssetPath)
        {
            // Compute world-space bounds of the map's tile region. Tile N spans world [N, N+1]
            // (edge-aligned), so the rect spans from MapMinTile to MapMaxTile + 1 on each axis.
            float tileSize = GridCoordinates.TILE_SIZE;
            float minX = ctx.MapMinTile.x * tileSize;
            float maxX = (ctx.MapMaxTile.x + 1) * tileSize;
            float minZ = ctx.MapMinTile.y * tileSize;
            float maxZ = (ctx.MapMaxTile.y + 1) * tileSize;
            float worldW = maxX - minX;
            float worldH = maxZ - minZ;

            // Aspect-matched non-square render with longer side at 1024px.
            int rtWidth, rtHeight;
            if (worldW >= worldH)
            {
                rtWidth = 1024;
                rtHeight = Mathf.Max(1, Mathf.RoundToInt(1024f * (worldH / worldW)));
            }
            else
            {
                rtHeight = 1024;
                rtWidth = Mathf.Max(1, Mathf.RoundToInt(1024f * (worldW / worldH)));
            }

            // Hide _LevelAuthoring subtree during the render so volume gizmos don't show up
            // (gizmos won't render in offscreen captures anyway, but child GameObjects with
            // visible meshes — labeling helpers, debug cubes etc. — would).
            var authGO = ctx.Authoring.gameObject;
            bool wasActive = authGO.activeSelf;
            authGO.SetActive(false);

            GameObject camGO = null;
            Camera cam = null;
            RenderTexture rt = null;
            RenderTexture prevActive = RenderTexture.active;
            Texture2D snapshot = null;
            try
            {
                // Allocate via a descriptor and force GPU creation up front with Create().
                // A manually-newed MSAA RenderTexture that is only lazily created (during
                // the camera's first Render) registers inconsistently with Unity's internal
                // RenderTexture age-check pool. On teardown that leaves a dangling tracker
                // entry, producing the editor-update spam:
                //   "Checking lifetime of RenderTextures but m_AgeCheckAdded = false".
                // Explicit Create() makes registration deterministic so Release() cleanly
                // unregisters it.
                var desc = new RenderTextureDescriptor(rtWidth, rtHeight, RenderTextureFormat.ARGB32, 24)
                {
                    msaaSamples = 4,
                    autoGenerateMips = false,
                };
                rt = new RenderTexture(desc);
                rt.Create();

                camGO = new GameObject("__BakeThumbnailCamera");
                camGO.hideFlags = HideFlags.HideAndDontSave;
                cam = camGO.AddComponent<Camera>();
                cam.clearFlags = CameraClearFlags.SolidColor;
                cam.backgroundColor = new Color(0.15f, 0.15f, 0.18f, 1f);
                cam.orthographic = true;
                // orthographicSize is HALF the vertical world extent. We sized the render texture
                // so that aspect = worldW/worldH, which makes the camera's horizontal world extent
                // exactly worldW when the vertical extent is worldH. So setting size = worldH/2
                // makes the map fit exactly in both branches.
                cam.orthographicSize = worldH * 0.5f;
                // Center camera above map.
                float camX = (minX + maxX) * 0.5f;
                float camZ = (minZ + maxZ) * 0.5f;
                cam.transform.position = new Vector3(camX, 50f, camZ);
                cam.transform.rotation = Quaternion.Euler(90f, 0f, 0f); // straight down
                cam.nearClipPlane = 0.1f;
                cam.farClipPlane = 100f;
                cam.targetTexture = rt; // aspect is derived from the texture automatically

                cam.Render();

                RenderTexture.active = rt;
                snapshot = new Texture2D(rtWidth, rtHeight, TextureFormat.ARGB32, false);
                snapshot.ReadPixels(new Rect(0, 0, rtWidth, rtHeight), 0, 0);
                snapshot.Apply();

                byte[] png = snapshot.EncodeToPNG();
                File.WriteAllBytes(thumbnailAssetPath, png);
            }
            finally
            {
                authGO.SetActive(wasActive);

                // Teardown order matters: clear every reference to the RT before destroying
                // it, otherwise the camera (or the global active slot) is left pointing at
                // freed memory, which is the other way the age-check tracker gets corrupted.
                if (cam != null) cam.targetTexture = null;
                RenderTexture.active = prevActive;
                if (camGO != null) UnityEngine.Object.DestroyImmediate(camGO);
                if (rt != null) { rt.Release(); UnityEngine.Object.DestroyImmediate(rt); }
                if (snapshot != null) UnityEngine.Object.DestroyImmediate(snapshot);
            }

            // Import as Sprite.
            AssetDatabase.ImportAsset(thumbnailAssetPath, ImportAssetOptions.ForceSynchronousImport);
            var importer = AssetImporter.GetAtPath(thumbnailAssetPath) as TextureImporter;
            if (importer != null)
            {
                importer.textureType = TextureImporterType.Sprite;
                importer.spriteImportMode = SpriteImportMode.Single;
                importer.SaveAndReimport();
            }
            return true;
        }

        // -------------------------------------------------------------------
        // PHASE 7 — Commit
        // -------------------------------------------------------------------

        private static void Phase7_Commit(BakeContext ctx, BakeReport report)
        {
            var target = ctx.Authoring.targetAsset;
            var src = ctx.AssembledData;

            // Field-by-field copy onto the target asset (preserves GUID).
            target.MapName = src.MapName;
            target.PlayerCount = src.PlayerCount;
            target.MapDescription = src.MapDescription;
            target.Author = src.Author;
            target.ScenePath = src.ScenePath;
            target.AuthoringHash = src.AuthoringHash;
            target.LastBakeTimestamp = src.LastBakeTimestamp;
            target.LastBakeOutcome = src.LastBakeOutcome;
            target.LastBakeWarningCount = src.LastBakeWarningCount;
            target.GridOriginTile = src.GridOriginTile;
            target.GridSize = src.GridSize;
            target.PlacementGrid = src.PlacementGrid;
            target.WalkabilityGrid = src.WalkabilityGrid;
            target.OwnerGrid = src.OwnerGrid;
            target.MapAreaGrid = src.MapAreaGrid;
            target.PlayerZones = src.PlayerZones;
            target.Goals = src.Goals;

            // Wire up the thumbnail sprite reference if a thumbnail was rendered.
            if (!string.IsNullOrEmpty(ctx.ThumbnailPath))
            {
                target.MapThumbnail = AssetDatabase.LoadAssetAtPath<Sprite>(ctx.ThumbnailPath);
            }

            EditorUtility.SetDirty(target);
            AssetDatabase.SaveAssets();
            AssetDatabase.Refresh();

            // Discard the in-memory ScriptableObject we built.
            UnityEngine.Object.DestroyImmediate(src);
        }
    }

    // -------------------------------------------------------------------
    // Internal context passed between phases. Lives only for the duration of one bake run.
    // -------------------------------------------------------------------

    internal class BakeContext
    {
        public LevelAuthoring Authoring;

        // Phase 1 outputs
        public List<VolumeBase> AllVolumes;
        public List<PlayerZoneVolume> PlayerZoneVolumes;
        public List<SpawnerVolume> SpawnerVolumes;
        public List<LeakExitVolume> LeakExitVolumes;
        public List<GoalVolume> GoalVolumes;
        public List<MapAreaVolume> MapAreaVolumes;

        // Phase 2 outputs
        public HashSet<PlayerSlot> ExpectedOwners;
        public HashSet<PlayerSlot> DeclaredOwners;

        // Phase 3 outputs
        public Dictionary<VolumeBase, HashSet<Vector2Int>> PerVolumeTiles;
        public Dictionary<PlayerSlot, HashSet<Vector2Int>> PerOwnerZoneTiles;
        public List<HashSet<Vector2Int>> PerGoalTiles;
        public HashSet<Vector2Int> MapAreaTiles;
        public Vector2Int MapMinTile;
        public Vector2Int MapMaxTile;

        // Phase 4 outputs
        public Vector2Int GridOriginTile;
        public Vector2Int GridSize;
        public PlacementState[,] PlacementGrid2D;
        public bool[,] WalkabilityGrid2D;
        public bool[,] MapAreaGrid2D;

        // Phase 5 outputs
        public HashSet<PlayerSlot> GoalAdjacentZones;

        // Phase 6 outputs
        public LevelData AssembledData;
        public string ThumbnailPath;
    }
}