using System.Text.Json;

namespace Theriapolis.Core.Data;

/// <summary>
/// Loads and validates all JSON content files from the Data directory.
/// Fails loudly on any missing file, broken reference, or malformed data.
/// </summary>
public sealed class ContentLoader
{
    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        ReadCommentHandling  = JsonCommentHandling.Skip,
        AllowTrailingCommas  = true,
        PropertyNameCaseInsensitive = true,
    };

    public string DataDirectory { get; }

    public ContentLoader(string dataDirectory)
    {
        DataDirectory = dataDirectory;
    }

    public MacroTemplate LoadMacroTemplate()
    {
        string path = Path.Combine(DataDirectory, "macro_template.json");
        var template = Load<MacroTemplate>(path);
        if (template.Width != C.MACRO_GRID_WIDTH || template.Height != C.MACRO_GRID_HEIGHT)
            Fail(path, $"Expected {C.MACRO_GRID_WIDTH}×{C.MACRO_GRID_HEIGHT}, got {template.Width}×{template.Height}");
        return template;
    }

    public BiomeDef[] LoadBiomes()
    {
        string path = Path.Combine(DataDirectory, "biomes.json");
        var biomes = Load<BiomeDef[]>(path);
        if (biomes.Length == 0)
            Fail(path, "Biome list is empty");
        var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var b in biomes)
        {
            if (string.IsNullOrWhiteSpace(b.Id))
                Fail(path, "Biome entry has empty id");
            if (!ids.Add(b.Id))
                Fail(path, $"Duplicate biome id: {b.Id}");
        }
        return biomes;
    }

    public FactionDef[] LoadFactions()
    {
        string path = Path.Combine(DataDirectory, "factions.json");
        var factions = Load<FactionDef[]>(path);
        if (factions.Length == 0)
            Fail(path, "Faction list is empty");
        var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var f in factions)
        {
            if (string.IsNullOrWhiteSpace(f.Id))
                Fail(path, "Faction entry has empty id");
            if (!ids.Add(f.Id))
                Fail(path, $"Duplicate faction id: {f.Id}");
        }
        return factions;
    }

    // ── Phase 5: character + content loaders ─────────────────────────────

    public CladeDef[] LoadClades()
    {
        string path = Path.Combine(DataDirectory, "clades.json");
        var clades = Load<CladeDef[]>(path);
        if (clades.Length == 0)
            Fail(path, "Clade list is empty");
        var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var c in clades)
        {
            if (string.IsNullOrWhiteSpace(c.Id))
                Fail(path, "Clade entry has empty id");
            if (!ids.Add(c.Id))
                Fail(path, $"Duplicate clade id: {c.Id}");
            if (c.Kind != "predator" && c.Kind != "prey")
                Fail(path, $"Clade '{c.Id}' has invalid kind '{c.Kind}' (expected 'predator' or 'prey')");
        }
        return clades;
    }

    public SpeciesDef[] LoadSpecies(IReadOnlyCollection<CladeDef> clades)
    {
        string path = Path.Combine(DataDirectory, "species.json");
        var species = Load<SpeciesDef[]>(path);
        if (species.Length == 0)
            Fail(path, "Species list is empty");
        var cladeIds = new HashSet<string>(clades.Select(c => c.Id), StringComparer.OrdinalIgnoreCase);
        var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var s in species)
        {
            if (string.IsNullOrWhiteSpace(s.Id))
                Fail(path, "Species entry has empty id");
            if (!ids.Add(s.Id))
                Fail(path, $"Duplicate species id: {s.Id}");
            if (!cladeIds.Contains(s.CladeId))
                Fail(path, $"Species '{s.Id}' references unknown clade_id '{s.CladeId}'");
        }
        return species;
    }

    public ClassDef[] LoadClasses()
    {
        string path = Path.Combine(DataDirectory, "classes.json");
        var classes = Load<ClassDef[]>(path);
        if (classes.Length == 0)
            Fail(path, "Class list is empty");
        var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var c in classes)
        {
            if (string.IsNullOrWhiteSpace(c.Id))
                Fail(path, "Class entry has empty id");
            if (!ids.Add(c.Id))
                Fail(path, $"Duplicate class id: {c.Id}");
            if (c.HitDie != 6 && c.HitDie != 8 && c.HitDie != 10 && c.HitDie != 12)
                Fail(path, $"Class '{c.Id}' has invalid hit_die {c.HitDie} (expected 6, 8, 10, or 12)");
            if (c.LevelTable.Length == 0)
                Fail(path, $"Class '{c.Id}' has empty level_table");
            // Level 1 must be present and have at least one feature
            var lv1 = Array.Find(c.LevelTable, e => e.Level == 1);
            if (lv1 is null)
                Fail(path, $"Class '{c.Id}' has no level 1 entry in level_table");
            // Cross-check feature ids against the feature_definitions dictionary
            foreach (var entry in c.LevelTable)
            foreach (var feat in entry.Features)
                if (!c.FeatureDefinitions.ContainsKey(feat))
                    Fail(path, $"Class '{c.Id}' level {entry.Level} references undefined feature '{feat}'");
        }
        return classes;
    }

    public SubclassDef[] LoadSubclasses(IReadOnlyCollection<ClassDef> classes)
    {
        string path = Path.Combine(DataDirectory, "subclasses.json");
        var subs = Load<SubclassDef[]>(path);
        // Empty allowed: subclasses are flavor in Phase 5; mechanics deferred.
        var classIds = new HashSet<string>(classes.Select(c => c.Id), StringComparer.OrdinalIgnoreCase);
        var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var s in subs)
        {
            if (string.IsNullOrWhiteSpace(s.Id))
                Fail(path, "Subclass entry has empty id");
            if (!ids.Add(s.Id))
                Fail(path, $"Duplicate subclass id: {s.Id}");
            if (!classIds.Contains(s.ClassId))
                Fail(path, $"Subclass '{s.Id}' references unknown class_id '{s.ClassId}'");
        }
        return subs;
    }

    public BackgroundDef[] LoadBackgrounds()
    {
        string path = Path.Combine(DataDirectory, "backgrounds.json");
        var bgs = Load<BackgroundDef[]>(path);
        if (bgs.Length == 0)
            Fail(path, "Background list is empty");
        var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var b in bgs)
        {
            if (string.IsNullOrWhiteSpace(b.Id))
                Fail(path, "Background entry has empty id");
            if (!ids.Add(b.Id))
                Fail(path, $"Duplicate background id: {b.Id}");
        }
        return bgs;
    }

    public ItemDef[] LoadItems()
    {
        string path = Path.Combine(DataDirectory, "items.json");
        var items = Load<ItemDef[]>(path);
        if (items.Length == 0)
            Fail(path, "Item list is empty");
        var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var validKinds = new HashSet<string> {
            "weapon", "armor", "shield", "consumable", "gear", "natural_weapon_enhancer"
        };
        foreach (var i in items)
        {
            if (string.IsNullOrWhiteSpace(i.Id))
                Fail(path, "Item entry has empty id");
            if (!ids.Add(i.Id))
                Fail(path, $"Duplicate item id: {i.Id}");
            if (!validKinds.Contains(i.Kind))
                Fail(path, $"Item '{i.Id}' has invalid kind '{i.Kind}'");
            if (i.Kind == "weapon" && string.IsNullOrWhiteSpace(i.Damage))
                Fail(path, $"Weapon item '{i.Id}' has empty damage expression");
            if (i.Kind == "armor" && i.AcBase <= 0)
                Fail(path, $"Armor item '{i.Id}' has non-positive ac_base {i.AcBase}");
        }
        return items;
    }

    public LootTableDef[] LoadLootTables(IReadOnlyCollection<ItemDef>? items = null)
    {
        string path = Path.Combine(DataDirectory, "loot_tables.json");
        if (!File.Exists(path)) return System.Array.Empty<LootTableDef>();
        var tables = Load<LootTableDef[]>(path);
        var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var itemIds = items is null
            ? null
            : new HashSet<string>(items.Select(i => i.Id), StringComparer.OrdinalIgnoreCase);
        foreach (var t in tables)
        {
            if (string.IsNullOrWhiteSpace(t.Id))
                Fail(path, "Loot table has empty id");
            if (!ids.Add(t.Id))
                Fail(path, $"Duplicate loot table id: {t.Id}");
            foreach (var d in t.Drops)
            {
                if (string.IsNullOrWhiteSpace(d.ItemId))
                    Fail(path, $"Loot table '{t.Id}' has empty item_id in a drop");
                if (itemIds is not null && !itemIds.Contains(d.ItemId))
                    Fail(path, $"Loot table '{t.Id}' references unknown item '{d.ItemId}'");
                if (d.Chance < 0f || d.Chance > 1f)
                    Fail(path, $"Loot table '{t.Id}' drop '{d.ItemId}' has chance {d.Chance} outside 0..1");
                if (d.QtyMin < 1 || d.QtyMax < d.QtyMin)
                    Fail(path, $"Loot table '{t.Id}' drop '{d.ItemId}' has invalid qty range {d.QtyMin}..{d.QtyMax}");
            }
        }
        return tables;
    }

    // ── Phase 6 M0: settlement stamp content ─────────────────────────────

    /// <summary>
    /// Load every JSON file in <c>Content/Data/building_templates/</c>. Each
    /// file is one <see cref="BuildingTemplateDef"/>. Returns an empty
    /// array if the directory doesn't exist (allows running tools/tests
    /// against installs that haven't authored templates yet).
    /// </summary>
    public BuildingTemplateDef[] LoadBuildingTemplates()
    {
        string dir = Path.Combine(DataDirectory, "building_templates");
        if (!Directory.Exists(dir)) return Array.Empty<BuildingTemplateDef>();

        var files = Directory.EnumerateFiles(dir, "*.json").OrderBy(p => p, StringComparer.Ordinal).ToArray();
        var defs  = new List<BuildingTemplateDef>(files.Length);
        var ids   = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var validDecos = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "counter", "bed", "hearth", "sign" };

        foreach (var path in files)
        {
            var def = Load<BuildingTemplateDef>(path);
            if (string.IsNullOrWhiteSpace(def.Id))
                Fail(path, "Building template has empty id");
            if (!ids.Add(def.Id))
                Fail(path, $"Duplicate building template id: {def.Id}");
            if (def.FootprintWTiles < 3 || def.FootprintHTiles < 3)
                Fail(path, $"Building '{def.Id}' footprint {def.FootprintWTiles}×{def.FootprintHTiles} is too small (need ≥3 each so an interior cell exists)");
            if (def.Doors.Length == 0)
                Fail(path, $"Building '{def.Id}' has no doors");
            foreach (var d in def.Doors)
            {
                if (d.X < 0 || d.X >= def.FootprintWTiles || d.Y < 0 || d.Y >= def.FootprintHTiles)
                    Fail(path, $"Building '{def.Id}' door ({d.X},{d.Y}) outside footprint");
                bool perimeter = d.X == 0 || d.Y == 0 || d.X == def.FootprintWTiles - 1 || d.Y == def.FootprintHTiles - 1;
                if (!perimeter)
                    Fail(path, $"Building '{def.Id}' door ({d.X},{d.Y}) is not on the perimeter");
            }
            foreach (var deco in def.Decos)
            {
                if (deco.X <= 0 || deco.Y <= 0 || deco.X >= def.FootprintWTiles - 1 || deco.Y >= def.FootprintHTiles - 1)
                    Fail(path, $"Building '{def.Id}' deco at ({deco.X},{deco.Y}) is on perimeter (interior only)");
                if (!validDecos.Contains(deco.Deco))
                    Fail(path, $"Building '{def.Id}' deco '{deco.Deco}' invalid (counter/bed/hearth/sign)");
            }
            foreach (var role in def.Roles)
            {
                if (string.IsNullOrWhiteSpace(role.Tag))
                    Fail(path, $"Building '{def.Id}' has a role with empty tag");
                if (role.SpawnAt.Length != 2)
                    Fail(path, $"Building '{def.Id}' role '{role.Tag}' spawn_at must be [x,y]");
                int rx = role.SpawnAt[0], ry = role.SpawnAt[1];
                if (rx <= 0 || ry <= 0 || rx >= def.FootprintWTiles - 1 || ry >= def.FootprintHTiles - 1)
                    Fail(path, $"Building '{def.Id}' role '{role.Tag}' spawn at ({rx},{ry}) is on perimeter (interior only)");
            }
            defs.Add(def);
        }
        return defs.ToArray();
    }

    /// <summary>
    /// Load every JSON file in <c>Content/Data/settlement_layouts/</c>.
    /// Files are individual <see cref="SettlementLayoutDef"/>s.
    /// </summary>
    public SettlementLayoutDef[] LoadSettlementLayouts(IReadOnlyCollection<BuildingTemplateDef>? buildings = null)
    {
        string dir = Path.Combine(DataDirectory, "settlement_layouts");
        if (!Directory.Exists(dir)) return Array.Empty<SettlementLayoutDef>();

        var files = Directory.EnumerateFiles(dir, "*.json").OrderBy(p => p, StringComparer.Ordinal).ToArray();
        var defs  = new List<SettlementLayoutDef>(files.Length);
        var ids   = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var buildingIds = buildings is null
            ? null
            : new HashSet<string>(buildings.Select(b => b.Id), StringComparer.OrdinalIgnoreCase);

        foreach (var path in files)
        {
            var def = Load<SettlementLayoutDef>(path);
            if (string.IsNullOrWhiteSpace(def.Id))
                Fail(path, "Settlement layout has empty id");
            if (!ids.Add(def.Id))
                Fail(path, $"Duplicate settlement layout id: {def.Id}");
            if (def.Kind != "preset" && def.Kind != "procedural")
                Fail(path, $"Settlement layout '{def.Id}' kind '{def.Kind}' invalid (preset/procedural)");

            if (def.Kind == "preset")
            {
                if (string.IsNullOrWhiteSpace(def.Anchor))
                    Fail(path, $"Preset layout '{def.Id}' missing anchor");
                if (def.Buildings.Length == 0)
                    Fail(path, $"Preset layout '{def.Id}' has no buildings");
                foreach (var b in def.Buildings)
                {
                    if (string.IsNullOrWhiteSpace(b.Template))
                        Fail(path, $"Preset layout '{def.Id}' has a placement with empty template");
                    if (b.Offset.Length != 2)
                        Fail(path, $"Preset layout '{def.Id}' placement of '{b.Template}' offset must be [x,y]");
                    if (buildingIds is not null && !buildingIds.Contains(b.Template))
                        Fail(path, $"Preset layout '{def.Id}' references unknown building template '{b.Template}'");
                }
            }
            else
            {
                if (def.Tier < 1 || def.Tier > 5)
                    Fail(path, $"Procedural layout '{def.Id}' tier {def.Tier} must be 1..5");
                if (def.CategoryWeights.Count == 0)
                    Fail(path, $"Procedural layout '{def.Id}' missing category_weights");
                if (def.TargetBuildingCount < 1)
                    Fail(path, $"Procedural layout '{def.Id}' target_building_count must be ≥ 1");
            }
            defs.Add(def);
        }

        // Anchor uniqueness
        var anchorIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var d in defs.Where(d => d.Kind == "preset"))
            if (!anchorIds.Add(d.Anchor))
                Fail(Path.Combine(dir, d.Id + ".json"), $"Multiple preset layouts target anchor '{d.Anchor}'");

        // Procedural tier uniqueness
        var tiers = new HashSet<int>();
        foreach (var d in defs.Where(d => d.Kind == "procedural"))
            if (!tiers.Add(d.Tier))
                Fail(Path.Combine(dir, d.Id + ".json"), $"Multiple procedural layouts for tier {d.Tier}");

        return defs.ToArray();
    }

    // ── Phase 6 M1: bias profiles + resident templates ──────────────────

    public BiasProfileDef[] LoadBiasProfiles(IReadOnlyCollection<CladeDef>? clades = null,
                                             IReadOnlyCollection<FactionDef>? factions = null)
    {
        string path = Path.Combine(DataDirectory, "bias_profiles.json");
        if (!File.Exists(path)) return Array.Empty<BiasProfileDef>();
        var defs = Load<BiasProfileDef[]>(path);
        var ids  = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var cladeIds = clades is null
            ? null
            : new HashSet<string>(clades.Select(c => c.Id), StringComparer.OrdinalIgnoreCase);
        var factionIds = factions is null
            ? null
            : new HashSet<string>(factions.Select(f => f.Id), StringComparer.OrdinalIgnoreCase);
        foreach (var p in defs)
        {
            if (string.IsNullOrWhiteSpace(p.Id))
                Fail(path, "Bias profile has empty id");
            if (!ids.Add(p.Id))
                Fail(path, $"Duplicate bias profile id: {p.Id}");
            if (cladeIds is not null)
                foreach (var cid in p.CladeBias.Keys)
                    if (!cladeIds.Contains(cid))
                        Fail(path, $"Bias profile '{p.Id}' references unknown clade '{cid}'");
            if (factionIds is not null)
                foreach (var fid in p.FactionAffinity.Keys)
                    if (!factionIds.Contains(fid))
                        Fail(path, $"Bias profile '{p.Id}' references unknown faction '{fid}'");
        }
        return defs;
    }

    public ResidentTemplateDef[] LoadResidentTemplates(
        IReadOnlyCollection<BiasProfileDef>? biasProfiles = null,
        IReadOnlyCollection<CladeDef>? clades = null,
        IReadOnlyCollection<SpeciesDef>? species = null,
        IReadOnlyCollection<FactionDef>? factions = null)
    {
        string path = Path.Combine(DataDirectory, "resident_templates.json");
        if (!File.Exists(path)) return Array.Empty<ResidentTemplateDef>();
        var defs = Load<ResidentTemplateDef[]>(path);
        var ids  = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var biasIds = biasProfiles is null
            ? null
            : new HashSet<string>(biasProfiles.Select(b => b.Id), StringComparer.OrdinalIgnoreCase);
        var cladeIds = clades is null
            ? null
            : new HashSet<string>(clades.Select(c => c.Id), StringComparer.OrdinalIgnoreCase);
        var speciesIds = species is null
            ? null
            : new HashSet<string>(species.Select(s => s.Id), StringComparer.OrdinalIgnoreCase);
        var factionIds = factions is null
            ? null
            : new HashSet<string>(factions.Select(f => f.Id), StringComparer.OrdinalIgnoreCase);
        foreach (var r in defs)
        {
            if (string.IsNullOrWhiteSpace(r.Id))
                Fail(path, "Resident template has empty id");
            if (!ids.Add(r.Id))
                Fail(path, $"Duplicate resident template id: {r.Id}");
            if (string.IsNullOrWhiteSpace(r.RoleTag))
                Fail(path, $"Resident template '{r.Id}' has empty role_tag");
            if (biasIds is not null && !biasIds.Contains(r.BiasProfile))
                Fail(path, $"Resident template '{r.Id}' references unknown bias profile '{r.BiasProfile}'");
            if (!string.IsNullOrEmpty(r.Clade) && cladeIds is not null && !cladeIds.Contains(r.Clade))
                Fail(path, $"Resident template '{r.Id}' references unknown clade '{r.Clade}'");
            if (!string.IsNullOrEmpty(r.Species) && speciesIds is not null && !speciesIds.Contains(r.Species))
                Fail(path, $"Resident template '{r.Id}' references unknown species '{r.Species}'");
            if (!string.IsNullOrEmpty(r.Faction) && factionIds is not null && !factionIds.Contains(r.Faction))
                Fail(path, $"Resident template '{r.Id}' references unknown faction '{r.Faction}'");
            if (r.Named && (string.IsNullOrEmpty(r.Clade) || string.IsNullOrEmpty(r.Species)))
                Fail(path, $"Named resident template '{r.Id}' must declare both clade and species");
            if (r.Named && string.IsNullOrEmpty(r.Name))
                Fail(path, $"Named resident template '{r.Id}' must declare a display name");
        }
        // Named role_tags must be unique — only one NPC can occupy "millhaven.innkeeper".
        var namedTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var r in defs)
        {
            if (!r.Named) continue;
            if (!namedTags.Add(r.RoleTag))
                Fail(path, $"Multiple named templates target role_tag '{r.RoleTag}'");
        }
        return defs;
    }

    // ── Phase 6 M3: dialogue trees ──────────────────────────────────────

    /// <summary>
    /// Load every JSON file in <c>Content/Data/dialogues/</c>. Each file is
    /// one <see cref="DialogueDef"/>. Returns an empty array when the
    /// directory doesn't exist (early-stage installs that haven't authored
    /// trees yet).
    /// </summary>
    public DialogueDef[] LoadDialogues(IReadOnlyCollection<ItemDef>? items = null)
    {
        string dir = Path.Combine(DataDirectory, "dialogues");
        if (!Directory.Exists(dir)) return Array.Empty<DialogueDef>();

        var files = Directory.EnumerateFiles(dir, "*.json").OrderBy(p => p, StringComparer.Ordinal).ToArray();
        var defs  = new List<DialogueDef>(files.Length);
        var ids   = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var itemIds = items is null
            ? null
            : new HashSet<string>(items.Select(i => i.Id), StringComparer.OrdinalIgnoreCase);

        var validConditions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        {
            "rep_at_least", "rep_below", "has_item", "not_has_item",
            "has_flag", "not_has_flag", "ability_min",
        };
        var validEffects = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        {
            "set_flag", "clear_flag", "give_item", "take_item",
            "rep_event", "open_shop", "start_quest", "give_xp",
        };

        foreach (var path in files)
        {
            var def = Load<DialogueDef>(path);
            if (string.IsNullOrWhiteSpace(def.Id))
                Fail(path, "Dialogue tree has empty id");
            if (!ids.Add(def.Id))
                Fail(path, $"Duplicate dialogue id: {def.Id}");
            if (string.IsNullOrWhiteSpace(def.Root))
                Fail(path, $"Dialogue '{def.Id}' has empty root id");
            if (def.Nodes.Length == 0)
                Fail(path, $"Dialogue '{def.Id}' has no nodes");

            // Node id uniqueness + reachability + reference checks.
            var nodeIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            foreach (var node in def.Nodes)
            {
                if (string.IsNullOrWhiteSpace(node.Id))
                    Fail(path, $"Dialogue '{def.Id}' node has empty id");
                if (!nodeIds.Add(node.Id))
                    Fail(path, $"Dialogue '{def.Id}' duplicate node id '{node.Id}'");
                if (node.Options.Length > C.DIALOGUE_MAX_OPTIONS_PER_NODE)
                    Fail(path, $"Dialogue '{def.Id}' node '{node.Id}' has {node.Options.Length} options (max {C.DIALOGUE_MAX_OPTIONS_PER_NODE})");
            }
            if (!nodeIds.Contains(def.Root))
                Fail(path, $"Dialogue '{def.Id}' root id '{def.Root}' not in node list");

            foreach (var node in def.Nodes)
            {
                foreach (var eff in node.OnEnter) ValidateEffect(path, def.Id, node.Id, eff, validEffects, itemIds);
                foreach (var opt in node.Options)
                {
                    foreach (var c in opt.Conditions)
                    {
                        if (!validConditions.Contains(c.Kind))
                            Fail(path, $"Dialogue '{def.Id}' node '{node.Id}' has unknown condition kind '{c.Kind}'");
                        if ((c.Kind == "has_item" || c.Kind == "not_has_item") &&
                            itemIds is not null && !string.IsNullOrEmpty(c.Id) && !itemIds.Contains(c.Id))
                            Fail(path, $"Dialogue '{def.Id}' references unknown item '{c.Id}'");
                    }
                    foreach (var eff in opt.Effects)         ValidateEffect(path, def.Id, node.Id, eff, validEffects, itemIds);
                    foreach (var eff in opt.EffectsOnSuccess) ValidateEffect(path, def.Id, node.Id, eff, validEffects, itemIds);
                    foreach (var eff in opt.EffectsOnFailure) ValidateEffect(path, def.Id, node.Id, eff, validEffects, itemIds);

                    string? next = NormaliseNext(opt.Next);
                    if (next is not null && !nodeIds.Contains(next))
                        Fail(path, $"Dialogue '{def.Id}' option in '{node.Id}' references unknown node '{next}'");
                    string? nextS = NormaliseNext(opt.NextOnSuccess);
                    if (nextS is not null && !nodeIds.Contains(nextS))
                        Fail(path, $"Dialogue '{def.Id}' option in '{node.Id}' references unknown node '{nextS}' (success)");
                    string? nextF = NormaliseNext(opt.NextOnFailure);
                    if (nextF is not null && !nodeIds.Contains(nextF))
                        Fail(path, $"Dialogue '{def.Id}' option in '{node.Id}' references unknown node '{nextF}' (failure)");

                    if (opt.SkillCheck is not null && string.IsNullOrEmpty(opt.SkillCheck.Skill))
                        Fail(path, $"Dialogue '{def.Id}' skill_check option in '{node.Id}' has empty skill");
                }
            }
            defs.Add(def);
        }
        return defs.ToArray();
    }

    private static string? NormaliseNext(string raw)
    {
        if (string.IsNullOrEmpty(raw)) return null;
        if (string.Equals(raw, "<end>", StringComparison.OrdinalIgnoreCase)) return null;
        return raw;
    }

    private void ValidateEffect(string path, string dialogueId, string nodeId, DialogueEffectDef eff,
                                HashSet<string> validKinds, HashSet<string>? itemIds)
    {
        if (!validKinds.Contains(eff.Kind))
            Fail(path, $"Dialogue '{dialogueId}' node '{nodeId}' has unknown effect kind '{eff.Kind}'");
        if ((eff.Kind == "give_item" || eff.Kind == "take_item")
            && itemIds is not null && !string.IsNullOrEmpty(eff.Id) && !itemIds.Contains(eff.Id))
            Fail(path, $"Dialogue '{dialogueId}' references unknown item '{eff.Id}'");
        if ((eff.Kind == "set_flag" || eff.Kind == "clear_flag") && string.IsNullOrEmpty(eff.Flag))
            Fail(path, $"Dialogue '{dialogueId}' set_flag/clear_flag missing flag id in '{nodeId}'");
    }

    // ── Phase 6 M4: quest trees ─────────────────────────────────────────

    /// <summary>
    /// Load every JSON file in <c>Content/Data/quests/</c>. Each file is
    /// one <see cref="QuestDef"/>. Validates structure + cross-refs.
    /// </summary>
    public QuestDef[] LoadQuests(IReadOnlyCollection<ItemDef>? items = null)
    {
        string dir = Path.Combine(DataDirectory, "quests");
        if (!Directory.Exists(dir)) return Array.Empty<QuestDef>();

        var files = Directory.EnumerateFiles(dir, "*.json").OrderBy(p => p, StringComparer.Ordinal).ToArray();
        var defs  = new List<QuestDef>(files.Length);
        var ids   = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var itemIds = items is null
            ? null
            : new HashSet<string>(items.Select(i => i.Id), StringComparer.OrdinalIgnoreCase);

        var validConditions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        {
            "flag_set", "flag_clear", "flag_at_least",
            "enter_anchor", "enter_role_proximity",
            "npc_dead", "npc_alive",
            "time_elapsed_seconds",
            "rep_at_least", "rep_below",
            "has_item", "not_has_item",
            "quest_complete", "quest_active",
            "dialogue_choice",
        };
        var validEffects = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        {
            "set_flag", "clear_flag", "give_item", "take_item",
            "give_xp", "rep_event",
            "spawn_npc", "despawn_npc",
            "start_quest", "end_quest", "fail_quest",
        };

        foreach (var path in files)
        {
            var def = Load<QuestDef>(path);
            if (string.IsNullOrWhiteSpace(def.Id))
                Fail(path, "Quest has empty id");
            if (!ids.Add(def.Id))
                Fail(path, $"Duplicate quest id: {def.Id}");
            if (string.IsNullOrWhiteSpace(def.EntryStep))
                Fail(path, $"Quest '{def.Id}' has empty entry_step");
            if (def.Steps.Length == 0)
                Fail(path, $"Quest '{def.Id}' has no steps");

            var stepIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            foreach (var step in def.Steps)
            {
                if (string.IsNullOrWhiteSpace(step.Id))
                    Fail(path, $"Quest '{def.Id}' has a step with empty id");
                if (!stepIds.Add(step.Id))
                    Fail(path, $"Quest '{def.Id}' duplicate step id '{step.Id}'");
            }
            if (!stepIds.Contains(def.EntryStep))
                Fail(path, $"Quest '{def.Id}' entry_step '{def.EntryStep}' not in step list");

            foreach (var cond in def.AutoStartWhen)
                ValidateQuestCondition(path, def.Id, "<auto_start>", cond, validConditions, itemIds);
            foreach (var step in def.Steps)
            {
                foreach (var cond in step.TriggerConditions)
                    ValidateQuestCondition(path, def.Id, step.Id, cond, validConditions, itemIds);
                foreach (var eff in step.OnEnter)
                    ValidateQuestEffect(path, def.Id, step.Id, eff, validEffects, itemIds);
                foreach (var outcome in step.Outcomes)
                {
                    foreach (var cond in outcome.When)
                        ValidateQuestCondition(path, def.Id, step.Id, cond, validConditions, itemIds);
                    foreach (var eff in outcome.Effects)
                        ValidateQuestEffect(path, def.Id, step.Id, eff, validEffects, itemIds);
                    string? n = NormaliseQuestNext(outcome.Next);
                    if (n is not null && !stepIds.Contains(n))
                        Fail(path, $"Quest '{def.Id}' step '{step.Id}' outcome.next '{n}' not in step list");
                }
            }
            defs.Add(def);
        }
        return defs.ToArray();
    }

    private static string? NormaliseQuestNext(string raw)
    {
        if (string.IsNullOrEmpty(raw)) return null;
        if (string.Equals(raw, "<end>", StringComparison.OrdinalIgnoreCase)) return null;
        return raw;
    }

    private void ValidateQuestCondition(string path, string questId, string where, QuestConditionDef c,
                                         HashSet<string> validKinds, HashSet<string>? itemIds)
    {
        if (!validKinds.Contains(c.Kind))
            Fail(path, $"Quest '{questId}' [{where}] has unknown condition kind '{c.Kind}'");
        if ((c.Kind == "has_item" || c.Kind == "not_has_item")
            && itemIds is not null && !string.IsNullOrEmpty(c.Id) && !itemIds.Contains(c.Id))
            Fail(path, $"Quest '{questId}' [{where}] references unknown item '{c.Id}'");
    }

    private void ValidateQuestEffect(string path, string questId, string stepId, QuestEffectDef e,
                                      HashSet<string> validKinds, HashSet<string>? itemIds)
    {
        if (!validKinds.Contains(e.Kind))
            Fail(path, $"Quest '{questId}' step '{stepId}' has unknown effect kind '{e.Kind}'");
        if ((e.Kind == "give_item" || e.Kind == "take_item")
            && itemIds is not null && !string.IsNullOrEmpty(e.Id) && !itemIds.Contains(e.Id))
            Fail(path, $"Quest '{questId}' references unknown item '{e.Id}'");
        if ((e.Kind == "set_flag" || e.Kind == "clear_flag") && string.IsNullOrEmpty(e.Flag))
            Fail(path, $"Quest '{questId}' step '{stepId}' set_flag/clear_flag missing flag id");
    }

    public NpcTemplateContent LoadNpcTemplates(IReadOnlyCollection<ItemDef>? items = null,
                                               IReadOnlyCollection<FactionDef>? factions = null)
    {
        string path = Path.Combine(DataDirectory, "npc_templates.json");
        var content = Load<NpcTemplateContent>(path);
        if (content.Templates.Length == 0)
            Fail(path, "NPC template list is empty");
        var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var factionIds = factions is null
            ? null
            : new HashSet<string>(factions.Select(f => f.Id), StringComparer.OrdinalIgnoreCase);
        foreach (var t in content.Templates)
        {
            if (string.IsNullOrWhiteSpace(t.Id))
                Fail(path, "NPC template has empty id");
            if (!ids.Add(t.Id))
                Fail(path, $"Duplicate NPC template id: {t.Id}");
            if (t.Hp <= 0)
                Fail(path, $"NPC template '{t.Id}' has non-positive hp {t.Hp}");
            if (t.Ac <= 0)
                Fail(path, $"NPC template '{t.Id}' has non-positive ac {t.Ac}");
            if (!string.IsNullOrEmpty(t.Faction) && factionIds is not null && !factionIds.Contains(t.Faction))
                Fail(path, $"NPC template '{t.Id}' references unknown faction '{t.Faction}'");
        }
        // Per-zone lookup must reference real templates if present
        foreach (var (kind, byZone) in content.SpawnKindToTemplateByZone)
        foreach (var tid in byZone)
            if (!ids.Contains(tid))
                Fail(path, $"spawn_kind_to_template_by_zone['{kind}'] references unknown template '{tid}'");
        // Phase 7 M2 — per-dungeon-type lookup must also reference real templates.
        var validDungeonTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { "ImperiumRuin", "AbandonedMine", "CultDen", "NaturalCave", "OvergrownSettlement" };
        foreach (var (dungeonType, byKind) in content.SpawnKindToTemplateByDungeonType)
        {
            if (!validDungeonTypes.Contains(dungeonType))
                Fail(path, $"spawn_kind_to_template_by_dungeon_type has invalid type '{dungeonType}'");
            foreach (var (kind, tid) in byKind)
                if (!ids.Contains(tid))
                    Fail(path, $"spawn_kind_to_template_by_dungeon_type['{dungeonType}']['{kind}'] references unknown template '{tid}'");
        }
        return content;
    }

    // ── Phase 7 M0: room templates + dungeon layouts ─────────────────────

    /// <summary>
    /// Load every JSON file under <c>Content/Data/room_templates/&lt;type&gt;/</c>
    /// (recursive scan). Each file is one <see cref="RoomTemplateDef"/>.
    /// Validates id uniqueness, grid dimensions vs declared footprint,
    /// perimeter wall completeness, and that every <c>D</c>/<c>@</c>/
    /// <c>C</c>/<c>T</c> char in the grid has a matching slot record.
    /// Returns an empty array when the directory doesn't exist (early-stage
    /// installs that haven't authored templates yet).
    /// </summary>
    public RoomTemplateDef[] LoadRoomTemplates()
    {
        string dir = Path.Combine(DataDirectory, "room_templates");
        if (!Directory.Exists(dir)) return Array.Empty<RoomTemplateDef>();

        // Recursive — typical layout is room_templates/imperium/*.json,
        // room_templates/mine/*.json, etc.
        var files = Directory.EnumerateFiles(dir, "*.json", SearchOption.AllDirectories)
                             .OrderBy(p => p, StringComparer.Ordinal).ToArray();
        var defs  = new List<RoomTemplateDef>(files.Length);
        var ids   = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        var validTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { "imperium", "mine", "cult", "cave", "overgrown" };
        var validBuiltBy = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { "canid", "felid", "mustelid", "ursid", "cervid", "bovid", "leporid", "imperium", "none" };
        var validSizes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { "small", "medium", "large" };
        var validRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { "entry", "transit", "narrative", "loot", "boss", "dead-end" };
        var validDecos = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { "pillar", "brazier", "mosaic", "imperium_statue" };
        var validLockTiers = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { "", "trivial", "easy", "medium", "hard" };
        var validBands = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { "t1", "t2", "t3" };
        var validSpawnKinds = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { "PoiGuard", "WildAnimal", "Brigand", "Boss" };

        foreach (var path in files)
        {
            var def = Load<RoomTemplateDef>(path);
            if (string.IsNullOrWhiteSpace(def.Id))
                Fail(path, "Room template has empty id");
            if (!ids.Add(def.Id))
                Fail(path, $"Duplicate room template id: {def.Id}");
            if (!validTypes.Contains(def.Type))
                Fail(path, $"Room template '{def.Id}' has invalid type '{def.Type}'");
            if (!validBuiltBy.Contains(def.BuiltBy))
                Fail(path, $"Room template '{def.Id}' has invalid built_by '{def.BuiltBy}'");
            if (!validSizes.Contains(def.SizeClass))
                Fail(path, $"Room template '{def.Id}' has invalid size_class '{def.SizeClass}'");
            foreach (var role in def.RolesEligible)
                if (!validRoles.Contains(role))
                    Fail(path, $"Room template '{def.Id}' references invalid role '{role}'");
            if (def.RolesEligible.Length == 0)
                Fail(path, $"Room template '{def.Id}' has no roles_eligible (must list at least one)");

            if (def.FootprintWTiles < 4 || def.FootprintHTiles < 4)
                Fail(path, $"Room template '{def.Id}' footprint {def.FootprintWTiles}×{def.FootprintHTiles} is too small (need ≥4 each)");
            if (def.Grid.Length != def.FootprintHTiles)
                Fail(path, $"Room template '{def.Id}' grid has {def.Grid.Length} rows but footprint_h_tiles is {def.FootprintHTiles}");
            for (int y = 0; y < def.Grid.Length; y++)
                if (def.Grid[y].Length != def.FootprintWTiles)
                    Fail(path, $"Room template '{def.Id}' grid row {y} has {def.Grid[y].Length} chars but footprint_w_tiles is {def.FootprintWTiles}");

            // Perimeter walls — each border cell must be '#'. Doors carve
            // through walls (tracked via the doors[] list, not the grid).
            int w = def.FootprintWTiles, h = def.FootprintHTiles;
            for (int x = 0; x < w; x++)
            {
                if (def.Grid[0][x] != '#' && def.Grid[0][x] != 'D' && def.Grid[0][x] != 'S')
                    Fail(path, $"Room template '{def.Id}' top perimeter cell ({x},0) is '{def.Grid[0][x]}' (expected '#'/'D'/'S')");
                if (def.Grid[h - 1][x] != '#' && def.Grid[h - 1][x] != 'D' && def.Grid[h - 1][x] != 'S')
                    Fail(path, $"Room template '{def.Id}' bottom perimeter cell ({x},{h - 1}) is '{def.Grid[h - 1][x]}' (expected '#'/'D'/'S')");
            }
            for (int y = 0; y < h; y++)
            {
                if (def.Grid[y][0] != '#' && def.Grid[y][0] != 'D' && def.Grid[y][0] != 'S')
                    Fail(path, $"Room template '{def.Id}' left perimeter cell (0,{y}) is '{def.Grid[y][0]}' (expected '#'/'D'/'S')");
                if (def.Grid[y][w - 1] != '#' && def.Grid[y][w - 1] != 'D' && def.Grid[y][w - 1] != 'S')
                    Fail(path, $"Room template '{def.Id}' right perimeter cell ({w - 1},{y}) is '{def.Grid[y][w - 1]}' (expected '#'/'D'/'S')");
            }

            // Slot-record consistency — every D/@/C/T char in the grid
            // must have a matching slot record at the same coords, and
            // vice versa.
            CheckSlotMatches(path, def, 'D', def.Doors.Select(d => (d.X, d.Y)), "doors");
            CheckSlotMatches(path, def, '@', def.EncounterSlots.Select(s => (s.X, s.Y)), "encounter_slots");
            CheckSlotMatches(path, def, 'C', def.ContainerSlots.Select(s => (s.X, s.Y)), "container_slots");
            CheckSlotMatches(path, def, 'T', def.TrapSlots.Select(s => (s.X, s.Y)), "trap_slots");

            // Door perimeter check
            foreach (var d in def.Doors)
            {
                if (d.X < 0 || d.X >= w || d.Y < 0 || d.Y >= h)
                    Fail(path, $"Room template '{def.Id}' door ({d.X},{d.Y}) outside footprint");
                bool perimeter = d.X == 0 || d.Y == 0 || d.X == w - 1 || d.Y == h - 1;
                if (!perimeter)
                    Fail(path, $"Room template '{def.Id}' door ({d.X},{d.Y}) is not on the perimeter");
                if (!validLockTiers.Contains(d.Lock))
                    Fail(path, $"Room template '{def.Id}' door ({d.X},{d.Y}) has invalid lock tier '{d.Lock}'");
            }
            foreach (var s in def.EncounterSlots)
            {
                if (s.X <= 0 || s.Y <= 0 || s.X >= w - 1 || s.Y >= h - 1)
                    Fail(path, $"Room template '{def.Id}' encounter slot ({s.X},{s.Y}) is on perimeter (interior only)");
                if (!validSpawnKinds.Contains(s.Kind))
                    Fail(path, $"Room template '{def.Id}' encounter slot kind '{s.Kind}' invalid");
            }
            foreach (var s in def.ContainerSlots)
            {
                if (s.X <= 0 || s.Y <= 0 || s.X >= w - 1 || s.Y >= h - 1)
                    Fail(path, $"Room template '{def.Id}' container slot ({s.X},{s.Y}) is on perimeter (interior only)");
                if (!validBands.Contains(s.LootTableBand))
                    Fail(path, $"Room template '{def.Id}' container slot band '{s.LootTableBand}' invalid");
                if (!validLockTiers.Contains(s.Lock))
                    Fail(path, $"Room template '{def.Id}' container slot ({s.X},{s.Y}) lock tier '{s.Lock}' invalid");
            }
            foreach (var s in def.TrapSlots)
            {
                if (s.X <= 0 || s.Y <= 0 || s.X >= w - 1 || s.Y >= h - 1)
                    Fail(path, $"Room template '{def.Id}' trap slot ({s.X},{s.Y}) is on perimeter (interior only)");
                if (s.Kind != "tripwire")
                    Fail(path, $"Room template '{def.Id}' trap kind '{s.Kind}' invalid (Phase 7 only ships tripwire)");
                if (s.DisarmDc != "trivial" && s.DisarmDc != "easy" && s.DisarmDc != "medium")
                    Fail(path, $"Room template '{def.Id}' trap disarm_dc '{s.DisarmDc}' invalid (trivial/easy/medium)");
            }
            foreach (var d in def.Decos)
            {
                if (d.X <= 0 || d.Y <= 0 || d.X >= w - 1 || d.Y >= h - 1)
                    Fail(path, $"Room template '{def.Id}' deco at ({d.X},{d.Y}) is on perimeter (interior only)");
                if (!validDecos.Contains(d.Deco))
                    Fail(path, $"Room template '{def.Id}' deco '{d.Deco}' invalid");
            }
            defs.Add(def);
        }
        return defs.ToArray();
    }

    private static void CheckSlotMatches(string path, RoomTemplateDef def, char ch,
        IEnumerable<(int x, int y)> slotCoords, string slotName)
    {
        // Collect every (x,y) position of `ch` in the grid.
        var gridPositions = new HashSet<(int, int)>();
        for (int y = 0; y < def.FootprintHTiles; y++)
        for (int x = 0; x < def.FootprintWTiles; x++)
            if (def.Grid[y][x] == ch) gridPositions.Add((x, y));

        var slotPositions = new HashSet<(int, int)>(slotCoords);

        foreach (var pos in gridPositions)
            if (!slotPositions.Contains(pos))
                Fail(path, $"Room template '{def.Id}' grid char '{ch}' at ({pos.Item1},{pos.Item2}) has no matching {slotName} record");
        foreach (var pos in slotPositions)
            if (!gridPositions.Contains(pos))
                Fail(path, $"Room template '{def.Id}' {slotName} record at ({pos.Item1},{pos.Item2}) has no matching grid '{ch}'");
    }

    /// <summary>
    /// Load every JSON file in <c>Content/Data/dungeon_layouts/</c>. Each
    /// file is one <see cref="DungeonLayoutDef"/>. Validates dungeon-type
    /// + size-band + branching + room-count band ranges + loot-table
    /// references against <paramref name="lootTables"/> when supplied.
    /// </summary>
    public DungeonLayoutDef[] LoadDungeonLayouts(IReadOnlyCollection<RoomTemplateDef>? rooms = null,
                                                  IReadOnlyCollection<LootTableDef>? lootTables = null)
    {
        string dir = Path.Combine(DataDirectory, "dungeon_layouts");
        if (!Directory.Exists(dir)) return Array.Empty<DungeonLayoutDef>();

        var files = Directory.EnumerateFiles(dir, "*.json")
                             .OrderBy(p => p, StringComparer.Ordinal).ToArray();
        var defs  = new List<DungeonLayoutDef>(files.Length);
        var ids   = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        var validTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { "ImperiumRuin", "AbandonedMine", "CultDen", "NaturalCave", "OvergrownSettlement" };
        var validBands = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { "small", "medium", "large" };
        var validBranching = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { "linear", "branching", "loop" };
        var validRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { "entry", "transit", "narrative", "loot", "boss", "dead-end" };
        var validLootBands = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { "t1", "t2", "t3" };

        var roomIds = rooms is null
            ? null
            : new HashSet<string>(rooms.Select(r => r.Id), StringComparer.OrdinalIgnoreCase);
        var lootTableIds = lootTables is null
            ? null
            : new HashSet<string>(lootTables.Select(t => t.Id), StringComparer.OrdinalIgnoreCase);

        foreach (var path in files)
        {
            var def = Load<DungeonLayoutDef>(path);
            if (string.IsNullOrWhiteSpace(def.Id))
                Fail(path, "Dungeon layout has empty id");
            if (!ids.Add(def.Id))
                Fail(path, $"Duplicate dungeon layout id: {def.Id}");
            if (!validTypes.Contains(def.DungeonType))
                Fail(path, $"Dungeon layout '{def.Id}' has invalid dungeon_type '{def.DungeonType}'");
            if (!validBands.Contains(def.SizeBand))
                Fail(path, $"Dungeon layout '{def.Id}' has invalid size_band '{def.SizeBand}'");
            if (!validBranching.Contains(def.Branching))
                Fail(path, $"Dungeon layout '{def.Id}' has invalid branching '{def.Branching}'");
            if (def.RoomCountMin < 1)
                Fail(path, $"Dungeon layout '{def.Id}' room_count_min {def.RoomCountMin} must be ≥ 1");
            if (def.RoomCountMax < def.RoomCountMin)
                Fail(path, $"Dungeon layout '{def.Id}' room_count_max {def.RoomCountMax} < room_count_min {def.RoomCountMin}");
            foreach (var role in def.RequiredRoles)
                if (!validRoles.Contains(role))
                    Fail(path, $"Dungeon layout '{def.Id}' required_role '{role}' invalid");
            foreach (var role in def.OptionalRoles)
                if (!validRoles.Contains(role))
                    Fail(path, $"Dungeon layout '{def.Id}' optional_role '{role}' invalid");
            foreach (var (band, table) in def.LootTablePerBand)
            {
                if (!validLootBands.Contains(band))
                    Fail(path, $"Dungeon layout '{def.Id}' loot_table_per_band has invalid key '{band}'");
                if (lootTableIds is not null && !lootTableIds.Contains(table))
                    Fail(path, $"Dungeon layout '{def.Id}' loot_table_per_band['{band}'] references unknown table '{table}'");
            }
            foreach (var (lvl, band) in def.LevelBandToLootBand)
            {
                if (!int.TryParse(lvl, out int lvlInt) || lvlInt < 0 || lvlInt > 3)
                    Fail(path, $"Dungeon layout '{def.Id}' level_band_to_loot_band key '{lvl}' must be 0..3");
                if (!validLootBands.Contains(band))
                    Fail(path, $"Dungeon layout '{def.Id}' level_band_to_loot_band[{lvl}] = '{band}' invalid");
            }
            float spawnSum = def.SpawnKindDistribution.Values.Sum();
            if (spawnSum > 0f && (spawnSum < 0.95f || spawnSum > 1.05f))
                Fail(path, $"Dungeon layout '{def.Id}' spawn_kind_distribution sums to {spawnSum:F3} (expected ≈1.0)");

            // Pinned rooms (anchor-locked layouts) must reference real templates.
            foreach (var pin in def.PinnedRooms)
            {
                if (string.IsNullOrWhiteSpace(pin.Template))
                    Fail(path, $"Dungeon layout '{def.Id}' pinned_room has empty template");
                if (!validRoles.Contains(pin.Role))
                    Fail(path, $"Dungeon layout '{def.Id}' pinned_room role '{pin.Role}' invalid");
                if (roomIds is not null && !roomIds.Contains(pin.Template))
                    Fail(path, $"Dungeon layout '{def.Id}' pinned_room references unknown template '{pin.Template}'");
            }
            // Anchor-locked layouts skip the random-room-count contract; the
            // pinned list IS the layout. Still: if PinnedRooms is set, its
            // length should fall in [min, max] for sanity.
            if (def.PinnedRooms.Length > 0)
            {
                if (def.PinnedRooms.Length < def.RoomCountMin || def.PinnedRooms.Length > def.RoomCountMax)
                    Fail(path, $"Dungeon layout '{def.Id}' has {def.PinnedRooms.Length} pinned rooms outside [{def.RoomCountMin},{def.RoomCountMax}] range");
            }
            defs.Add(def);
        }

        // Anchor uniqueness — only one layout per anchor.
        var anchorIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var d in defs.Where(d => !string.IsNullOrEmpty(d.Anchor)))
            if (!anchorIds.Add(d.Anchor))
                Fail(Path.Combine(dir, d.Id + ".json"), $"Multiple dungeon layouts target anchor '{d.Anchor}'");

        return defs.ToArray();
    }

    private T Load<T>(string path)
    {
        if (!File.Exists(path))
            throw new FileNotFoundException($"Required content file not found: {path}");
        try
        {
            using var fs = File.OpenRead(path);
            var result = JsonSerializer.Deserialize<T>(fs, JsonOptions);
            if (result is null)
                Fail(path, "Deserialized to null");
            return result!;
        }
        catch (JsonException ex)
        {
            throw new InvalidDataException($"JSON parse error in {path}: {ex.Message}", ex);
        }
    }

    private static void Fail(string path, string reason) =>
        throw new InvalidDataException($"Content validation failed for {path}: {reason}");
}
