// validate.ts — WorldProject validation
import { validateSpawnCondition } from './spawn-condition.js';
/**
 * All valid connection kinds — derived from the ConnectionKind union type.
 * If you add a new ConnectionKind in spatial.ts, add it here too.
 * A compile-time exhaustiveness check below ensures this stays in sync.
 */
const VALID_CONNECTION_KINDS_ARRAY = [
    'passage', 'door', 'stairs', 'road', 'portal', 'secret', 'hazard',
    'channel', 'route', 'docking', 'warp', 'trail',
];
export const VALID_CONNECTION_KINDS = new Set(VALID_CONNECTION_KINDS_ARRAY);
// Compile-time exhaustiveness: if a new ConnectionKind is added, this will error
// until the array above is updated.
function _assertExhaustiveConnectionKind(kind) {
    const lookup = {
        passage: true, door: true, stairs: true, road: true, portal: true,
        secret: true, hazard: true, channel: true, route: true, docking: true,
        warp: true, trail: true,
    };
    lookup[kind]; // compile-time check — unused at runtime
}
void _assertExhaustiveConnectionKind;
/**
 * All valid asset kinds — derived from the AssetKind union type.
 * If you add a new AssetKind in assets.ts, add it here too.
 * A compile-time exhaustiveness check below ensures this stays in sync.
 */
const VALID_ASSET_KINDS_ARRAY = ['portrait', 'sprite', 'background', 'icon', 'tileset'];
export const VALID_ASSET_KINDS = new Set(VALID_ASSET_KINDS_ARRAY);
// Compile-time exhaustiveness: if a new AssetKind is added, this will error
// until the array above is updated.
function _assertExhaustiveAssetKind(kind) {
    const lookup = {
        portrait: true, sprite: true, background: true, icon: true, tileset: true,
    };
    lookup[kind]; // compile-time check — unused at runtime
}
void _assertExhaustiveAssetKind;
/** Semver pattern for pack version validation (x.y.z). */
const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
export function validateProject(project, options) {
    const errors = [];
    let warningCount = 0;
    const verbose = options?.verbose ?? false;
    // 1. At least one spawn point
    if (project.spawnPoints.length === 0) {
        errors.push({ path: 'spawnPoints', message: 'At least one spawn point is required' });
    }
    // 2. At least one default spawn point
    if (project.spawnPoints.length > 0 && !project.spawnPoints.some((sp) => sp.isDefault)) {
        errors.push({ path: 'spawnPoints', message: 'At least one spawn point must be marked as default' });
    }
    // 3. ID uniqueness — zones
    const zoneIds = new Set();
    for (const z of project.zones) {
        if (zoneIds.has(z.id)) {
            errors.push({ path: `zones.${z.id}`, message: `Duplicate zone ID: ${z.id}` });
        }
        zoneIds.add(z.id);
    }
    // 4. ID uniqueness — districts
    const districtIds = new Set();
    for (const d of project.districts) {
        if (districtIds.has(d.id)) {
            errors.push({ path: `districts.${d.id}`, message: `Duplicate district ID: ${d.id}` });
        }
        districtIds.add(d.id);
    }
    // 5. Zone neighbors must reference existing zones
    for (const z of project.zones) {
        for (const nid of z.neighbors) {
            if (!zoneIds.has(nid)) {
                errors.push({ path: `zones.${z.id}.neighbors`, message: `Zone "${z.id}" references nonexistent neighbor "${nid}"` });
            }
        }
    }
    // 6. Symmetrical neighbors — if A lists B, B should list A
    // Build zone map and neighbor Sets for O(1) lookups (avoids O(n²) find + O(n³) includes)
    const zoneMap = new Map(project.zones.map((z) => [z.id, z]));
    const neighborSets = new Map(project.zones.map((z) => [z.id, new Set(z.neighbors)]));
    for (const z of project.zones) {
        for (const nid of z.neighbors) {
            // Skip symmetry check for nonexistent neighbors — already reported in step 5.
            // Without this guard, we'd silently skip instead of making intent clear.
            if (!neighborSets.has(nid))
                continue;
            const neighborSet = neighborSets.get(nid);
            if (!neighborSet.has(z.id)) {
                errors.push({
                    path: `zones.${z.id}.neighbors`,
                    message: `Zone "${z.id}" lists "${nid}" as neighbor, but "${nid}" does not list "${z.id}" back`,
                });
            }
        }
    }
    // 7. District zoneIds must reference existing zones
    for (const d of project.districts) {
        for (const zid of d.zoneIds) {
            if (!zoneIds.has(zid)) {
                errors.push({ path: `districts.${d.id}.zoneIds`, message: `District "${d.id}" references nonexistent zone "${zid}"` });
            }
        }
    }
    // 8. Entity placements must reference valid zones
    for (const ep of project.entityPlacements) {
        if (!zoneIds.has(ep.zoneId)) {
            errors.push({ path: `entityPlacements.${ep.entityId}`, message: `Entity "${ep.entityId}" placed in nonexistent zone "${ep.zoneId}"` });
        }
    }
    // 9. Item placements must reference valid zones
    for (const ip of project.itemPlacements) {
        if (!zoneIds.has(ip.zoneId)) {
            errors.push({ path: `itemPlacements.${ip.itemId}`, message: `Item "${ip.itemId}" placed in nonexistent zone "${ip.zoneId}"` });
        }
    }
    // 10. Spawn points must reference valid zones
    for (const sp of project.spawnPoints) {
        if (!zoneIds.has(sp.zoneId)) {
            errors.push({ path: `spawnPoints.${sp.id}`, message: `Spawn point "${sp.id}" in nonexistent zone "${sp.zoneId}"` });
        }
    }
    // 11. Connections must reference valid zones (kinds validated against VALID_CONNECTION_KINDS — module-level constant)
    for (const c of project.connections) {
        if (!zoneIds.has(c.fromZoneId)) {
            errors.push({ path: 'connections', message: `Connection references nonexistent zone "${c.fromZoneId}"` });
        }
        if (!zoneIds.has(c.toZoneId)) {
            errors.push({ path: 'connections', message: `Connection references nonexistent zone "${c.toZoneId}"` });
        }
        // 11b. Connection kind must be valid
        if (c.kind && !VALID_CONNECTION_KINDS.has(c.kind)) {
            errors.push({ path: 'connections', message: `Connection has unsupported kind "${c.kind}"` });
        }
    }
    // 12. Landmark zoneIds must exist
    for (const lm of project.landmarks) {
        if (!zoneIds.has(lm.zoneId)) {
            errors.push({ path: `landmarks.${lm.id}`, message: `Landmark "${lm.id}" in nonexistent zone "${lm.zoneId}"` });
        }
    }
    // 13a. Encounter anchor ID uniqueness
    const encounterIds = new Set();
    for (const ea of project.encounterAnchors) {
        if (encounterIds.has(ea.id)) {
            errors.push({ path: `encounterAnchors.${ea.id}`, message: `Duplicate encounter anchor ID: ${ea.id}` });
        }
        encounterIds.add(ea.id);
    }
    // 13b. Encounter anchor zoneId must exist
    for (const ea of project.encounterAnchors) {
        if (!zoneIds.has(ea.zoneId)) {
            errors.push({ path: `encounterAnchors.${ea.id}`, message: `Encounter anchor "${ea.id}" in nonexistent zone "${ea.zoneId}"` });
        }
    }
    // 13c. Encounter anchor encounterType must be non-empty and not whitespace-only
    for (const ea of project.encounterAnchors) {
        if (!ea.encounterType || ea.encounterType.trim().length === 0) {
            errors.push({
                path: `encounterAnchors.${ea.id}`,
                message: `Encounter anchor "${ea.id}" has ${!ea.encounterType ? 'missing' : 'whitespace-only'} encounterType — provide a type like "combat", "ambush", or "random"`,
            });
        }
    }
    // 14. Faction districtIds must reference valid districts
    for (const fp of project.factionPresences) {
        for (const did of fp.districtIds) {
            if (!districtIds.has(did)) {
                errors.push({ path: `factionPresences.${fp.factionId}`, message: `Faction "${fp.factionId}" references nonexistent district "${did}"` });
            }
        }
    }
    // 15a. Pressure hotspot ID uniqueness
    const hotspotIds = new Set();
    for (const ph of project.pressureHotspots) {
        if (hotspotIds.has(ph.id)) {
            errors.push({ path: `pressureHotspots.${ph.id}`, message: `Duplicate pressure hotspot ID: ${ph.id}` });
        }
        hotspotIds.add(ph.id);
    }
    // 15b. Pressure hotspot zoneId must exist
    for (const ph of project.pressureHotspots) {
        if (!zoneIds.has(ph.zoneId)) {
            errors.push({ path: `pressureHotspots.${ph.id}`, message: `Pressure hotspot "${ph.id}" in nonexistent zone "${ph.zoneId}"` });
        }
    }
    // --- Dialogue validation ---
    const dialogueIds = new Set();
    for (const dlg of project.dialogues) {
        // 16. Dialogue ID uniqueness
        if (dialogueIds.has(dlg.id)) {
            errors.push({ path: `dialogues.${dlg.id}`, message: `Duplicate dialogue ID: ${dlg.id}` });
        }
        dialogueIds.add(dlg.id);
        // 17. Entry node must exist
        if (!dlg.nodes[dlg.entryNodeId]) {
            errors.push({
                path: `dialogues.${dlg.id}.entryNodeId`,
                message: `Dialogue "${dlg.id}" entry node "${dlg.entryNodeId}" does not exist in nodes`,
            });
        }
        // 18. All nextNodeId references must point to existing nodes
        const nodeIds = new Set(Object.keys(dlg.nodes));
        for (const [nodeId, node] of Object.entries(dlg.nodes)) {
            if (node.nextNodeId && !nodeIds.has(node.nextNodeId)) {
                errors.push({
                    path: `dialogues.${dlg.id}.nodes.${nodeId}.nextNodeId`,
                    message: `Node "${nodeId}" auto-advances to nonexistent node "${node.nextNodeId}"`,
                });
            }
            if (node.choices) {
                for (const choice of node.choices) {
                    if (!nodeIds.has(choice.nextNodeId)) {
                        errors.push({
                            path: `dialogues.${dlg.id}.nodes.${nodeId}.choices.${choice.id}`,
                            message: `Choice "${choice.id}" in node "${nodeId}" points to nonexistent node "${choice.nextNodeId}"`,
                        });
                    }
                }
            }
        }
        // 19. Unreachable nodes (not reachable from entry)
        if (dlg.nodes[dlg.entryNodeId]) {
            const reachable = new Set();
            const queue = [dlg.entryNodeId];
            while (queue.length > 0) {
                const current = queue.pop();
                if (!current || reachable.has(current))
                    continue;
                reachable.add(current);
                const nd = dlg.nodes[current];
                if (!nd)
                    continue;
                if (nd.nextNodeId && nodeIds.has(nd.nextNodeId))
                    queue.push(nd.nextNodeId);
                if (nd.choices) {
                    for (const ch of nd.choices) {
                        if (nodeIds.has(ch.nextNodeId))
                            queue.push(ch.nextNodeId);
                    }
                }
            }
            for (const nid of nodeIds) {
                if (!reachable.has(nid)) {
                    errors.push({
                        path: `dialogues.${dlg.id}.nodes.${nid}`,
                        message: `Node "${nid}" in dialogue "${dlg.id}" is unreachable from entry node`,
                    });
                }
            }
        }
    }
    // 20. Entity dialogueId must reference existing dialogue
    for (const ep of project.entityPlacements) {
        if (ep.dialogueId && !dialogueIds.has(ep.dialogueId)) {
            errors.push({
                path: `entityPlacements.${ep.entityId}.dialogueId`,
                message: `Entity "${ep.entityId}" references nonexistent dialogue "${ep.dialogueId}"`,
            });
        }
    }
    // --- Player template validation ---
    const spawnPointIds = new Set(project.spawnPoints.map((sp) => sp.id));
    if (project.playerTemplate) {
        const pt = project.playerTemplate;
        // 21. Spawn point must exist
        if (!spawnPointIds.has(pt.spawnPointId)) {
            errors.push({
                path: 'playerTemplate.spawnPointId',
                message: `Player template spawn point "${pt.spawnPointId}" does not exist`,
            });
        }
        // 22. Starting inventory items should exist in item placements
        const itemIds = new Set(project.itemPlacements.map((ip) => ip.itemId));
        for (const itemId of pt.startingInventory) {
            if (!itemIds.has(itemId)) {
                errors.push({
                    path: `playerTemplate.startingInventory`,
                    message: `Player template starting item "${itemId}" not found in item placements`,
                });
            }
        }
        // 23. Starting equipment items should exist
        for (const [slot, itemId] of Object.entries(pt.startingEquipment)) {
            if (!itemIds.has(itemId)) {
                errors.push({
                    path: `playerTemplate.startingEquipment.${slot}`,
                    message: `Player template equipment "${itemId}" in slot "${slot}" not found in item placements`,
                });
            }
        }
        // 24. Default archetype/background requires buildCatalog to exist
        if (pt.defaultArchetypeId && !project.buildCatalog) {
            errors.push({
                path: 'playerTemplate.defaultArchetypeId',
                message: `Player template references archetype "${pt.defaultArchetypeId}" but no buildCatalog is defined. Add a buildCatalog with archetypes[], or clear playerTemplate.defaultArchetypeId.`,
            });
        }
        if (pt.defaultBackgroundId && !project.buildCatalog) {
            errors.push({
                path: 'playerTemplate.defaultBackgroundId',
                message: `Player template references background "${pt.defaultBackgroundId}" but no buildCatalog is defined. Add a buildCatalog with backgrounds[], or clear playerTemplate.defaultBackgroundId.`,
            });
        }
        // 25. Default archetype must exist in build catalog
        if (pt.defaultArchetypeId && project.buildCatalog) {
            if (!project.buildCatalog.archetypes.some((a) => a.id === pt.defaultArchetypeId)) {
                errors.push({
                    path: 'playerTemplate.defaultArchetypeId',
                    message: `Player template references archetype "${pt.defaultArchetypeId}" which is not in buildCatalog.archetypes[]. Add it to the catalog, or pick an existing archetype id.`,
                });
            }
        }
        // 26. Default background must exist in build catalog
        if (pt.defaultBackgroundId && project.buildCatalog) {
            if (!project.buildCatalog.backgrounds.some((b) => b.id === pt.defaultBackgroundId)) {
                errors.push({
                    path: 'playerTemplate.defaultBackgroundId',
                    message: `Player template references background "${pt.defaultBackgroundId}" which is not in buildCatalog.backgrounds[]. Add it to the catalog, or pick an existing background id.`,
                });
            }
        }
    }
    // --- Build catalog validation ---
    if (project.buildCatalog) {
        const bc = project.buildCatalog;
        const archetypeIds = new Set();
        const traitIds = new Set();
        const disciplineIds = new Set();
        // 27. Archetype ID uniqueness + progression tree refs
        const progressionTreeIds = new Set(project.progressionTrees.map((t) => t.id));
        for (const arch of bc.archetypes) {
            if (archetypeIds.has(arch.id)) {
                errors.push({ path: `buildCatalog.archetypes.${arch.id}`, message: `Duplicate archetype ID: ${arch.id}` });
            }
            archetypeIds.add(arch.id);
            if (!progressionTreeIds.has(arch.progressionTreeId)) {
                errors.push({
                    path: `buildCatalog.archetypes.${arch.id}.progressionTreeId`,
                    message: `Archetype "${arch.id}" references nonexistent progression tree "${arch.progressionTreeId}"`,
                });
            }
        }
        // 28. Background ID uniqueness
        const backgroundIds = new Set();
        for (const bg of bc.backgrounds) {
            if (backgroundIds.has(bg.id)) {
                errors.push({ path: `buildCatalog.backgrounds.${bg.id}`, message: `Duplicate background ID: ${bg.id}` });
            }
            backgroundIds.add(bg.id);
        }
        // 29. Trait ID uniqueness + incompatibility refs
        for (const trait of bc.traits) {
            if (traitIds.has(trait.id)) {
                errors.push({ path: `buildCatalog.traits.${trait.id}`, message: `Duplicate trait ID: ${trait.id}` });
            }
            traitIds.add(trait.id);
        }
        for (const trait of bc.traits) {
            if (trait.incompatibleWith) {
                for (const incompat of trait.incompatibleWith) {
                    if (!traitIds.has(incompat)) {
                        errors.push({
                            path: `buildCatalog.traits.${trait.id}.incompatibleWith`,
                            message: `Trait "${trait.id}" lists incompatible trait "${incompat}" that does not exist`,
                        });
                    }
                }
            }
        }
        // 30. Discipline ID uniqueness
        for (const disc of bc.disciplines) {
            if (disciplineIds.has(disc.id)) {
                errors.push({ path: `buildCatalog.disciplines.${disc.id}`, message: `Duplicate discipline ID: ${disc.id}` });
            }
            disciplineIds.add(disc.id);
        }
        // 31. Cross-title refs must point to existing archetypes + disciplines
        for (const ct of bc.crossTitles) {
            if (!archetypeIds.has(ct.archetypeId)) {
                errors.push({
                    path: `buildCatalog.crossTitles`,
                    message: `Cross-title references nonexistent archetype "${ct.archetypeId}"`,
                });
            }
            if (!disciplineIds.has(ct.disciplineId)) {
                errors.push({
                    path: `buildCatalog.crossTitles`,
                    message: `Cross-title references nonexistent discipline "${ct.disciplineId}"`,
                });
            }
        }
        // 32. Entanglement refs must point to existing archetypes + disciplines
        for (const ent of bc.entanglements) {
            if (!archetypeIds.has(ent.archetypeId)) {
                errors.push({
                    path: `buildCatalog.entanglements.${ent.id}`,
                    message: `Entanglement "${ent.id}" references nonexistent archetype "${ent.archetypeId}"`,
                });
            }
            if (!disciplineIds.has(ent.disciplineId)) {
                errors.push({
                    path: `buildCatalog.entanglements.${ent.id}`,
                    message: `Entanglement "${ent.id}" references nonexistent discipline "${ent.disciplineId}"`,
                });
            }
        }
    }
    // --- Progression tree validation ---
    const treeIds = new Set();
    for (const tree of project.progressionTrees) {
        // 33. Tree ID uniqueness
        if (treeIds.has(tree.id)) {
            errors.push({ path: `progressionTrees.${tree.id}`, message: `Duplicate progression tree ID: ${tree.id}` });
        }
        treeIds.add(tree.id);
        const nodeIds = new Set(tree.nodes.map((n) => n.id));
        const nodeIdDupes = new Set();
        for (const node of tree.nodes) {
            // 34. Node ID uniqueness within tree
            if (nodeIdDupes.has(node.id)) {
                errors.push({
                    path: `progressionTrees.${tree.id}.nodes.${node.id}`,
                    message: `Duplicate node ID "${node.id}" in tree "${tree.id}"`,
                });
            }
            nodeIdDupes.add(node.id);
            // 35. Required node refs must exist
            if (node.requires) {
                for (const reqId of node.requires) {
                    if (!nodeIds.has(reqId)) {
                        errors.push({
                            path: `progressionTrees.${tree.id}.nodes.${node.id}.requires`,
                            message: `Node "${node.id}" requires nonexistent node "${reqId}" in tree "${tree.id}"`,
                        });
                    }
                }
            }
        }
        // 36. Unreachable nodes (no requires = root, all others must be reachable)
        const roots = tree.nodes.filter((n) => !n.requires || n.requires.length === 0);
        if (roots.length === 0 && tree.nodes.length > 0) {
            errors.push({
                path: `progressionTrees.${tree.id}`,
                message: `Progression tree "${tree.id}" has no root nodes (all nodes have requirements)`,
            });
        }
    }
    // --- Asset validation ---
    // Asset kinds validated against VALID_ASSET_KINDS — module-level constant derived from AssetKind type
    const assetIds = new Set();
    const assetMap = new Map();
    // 37. Asset ID uniqueness
    for (const a of project.assets) {
        if (assetIds.has(a.id)) {
            errors.push({ path: `assets.${a.id}`, message: `Duplicate asset ID: ${a.id}` });
        }
        assetIds.add(a.id);
        assetMap.set(a.id, { kind: a.kind });
    }
    // 38. Asset path must be non-empty
    for (const a of project.assets) {
        if (!a.path || a.path.trim().length === 0) {
            errors.push({ path: `assets.${a.id}.path`, message: `Asset "${a.id}" has empty path` });
        }
    }
    // 39. Asset kind must be valid
    for (const a of project.assets) {
        if (!VALID_ASSET_KINDS.has(a.kind)) {
            errors.push({ path: `assets.${a.id}.kind`, message: `Asset "${a.id}" has unsupported kind "${a.kind}"` });
        }
    }
    // Helper: check asset ref exists and has correct kind.
    // Provides full source context (who references what, which field, expected kind)
    // so the user can locate and fix the issue quickly.
    //
    // Null/undefined refs are INTENTIONALLY SKIPPED — they express "field present
    // in schema but intentionally unset" (optional-field semantics). Typical
    // callers: zone.backgroundId, zone.tilesetId, entity.portraitId, entity.spriteId,
    // item.iconId, landmark.iconId. These become user-facing "missing asset"
    // warnings only at export time, not at schema-validation time.
    function checkAssetRef(refId, expectedKind, path, label) {
        // Null/undefined refId means the field is optional and unset — not an error.
        // (e.g. zone.backgroundId, entity.portraitId are optional asset bindings)
        if (!refId)
            return;
        if (!assetIds.has(refId)) {
            errors.push({
                path,
                message: `${label} references nonexistent asset "${refId}" (expected a "${expectedKind}" asset). Check that the asset ID is correct and exists in the assets array.`,
            });
            return;
        }
        const asset = assetMap.get(refId);
        if (asset && asset.kind !== expectedKind) {
            errors.push({
                path,
                message: `${label} references asset "${refId}" of kind "${asset.kind}", expected "${expectedKind}". Assign a "${expectedKind}" asset instead.`,
            });
        }
    }
    // 40-41. Zone asset refs
    for (const z of project.zones) {
        checkAssetRef(z.backgroundId, 'background', `zones.${z.id}.backgroundId`, `Zone "${z.id}"`);
        checkAssetRef(z.tilesetId, 'tileset', `zones.${z.id}.tilesetId`, `Zone "${z.id}"`);
    }
    // 42-43. Entity asset refs
    for (const ep of project.entityPlacements) {
        checkAssetRef(ep.portraitId, 'portrait', `entityPlacements.${ep.entityId}.portraitId`, `Entity "${ep.entityId}"`);
        checkAssetRef(ep.spriteId, 'sprite', `entityPlacements.${ep.entityId}.spriteId`, `Entity "${ep.entityId}"`);
    }
    // 44. Item asset refs
    for (const ip of project.itemPlacements) {
        checkAssetRef(ip.iconId, 'icon', `itemPlacements.${ip.itemId}.iconId`, `Item "${ip.itemId}"`);
    }
    // 45. Landmark asset refs
    for (const lm of project.landmarks) {
        checkAssetRef(lm.iconId, 'icon', `landmarks.${lm.id}.iconId`, `Landmark "${lm.id}"`);
    }
    // 46. Orphaned assets (in manifest but unreferenced)
    const referencedAssetIds = new Set();
    for (const z of project.zones) {
        if (z.backgroundId)
            referencedAssetIds.add(z.backgroundId);
        if (z.tilesetId)
            referencedAssetIds.add(z.tilesetId);
        // 2.5D (v4.2.0): skyline + parallax assetRefs also count as valid references.
        if (z.skylineRef)
            referencedAssetIds.add(z.skylineRef);
        if (z.parallaxLayers) {
            for (const layer of z.parallaxLayers) {
                if (layer.assetRef)
                    referencedAssetIds.add(layer.assetRef);
            }
        }
    }
    for (const ep of project.entityPlacements) {
        if (ep.portraitId)
            referencedAssetIds.add(ep.portraitId);
        if (ep.spriteId)
            referencedAssetIds.add(ep.spriteId);
    }
    for (const ip of project.itemPlacements) {
        if (ip.iconId)
            referencedAssetIds.add(ip.iconId);
    }
    for (const lm of project.landmarks) {
        if (lm.iconId)
            referencedAssetIds.add(lm.iconId);
    }
    for (const a of project.assets) {
        if (!referencedAssetIds.has(a.id)) {
            errors.push({ path: `assets.${a.id}`, message: `Asset "${a.id}" (${a.kind}) is not referenced by any zone, entity, item, or landmark` });
        }
    }
    // --- Asset pack validation ---
    const packIds = new Set();
    // 47. Pack ID uniqueness
    for (const pack of project.assetPacks) {
        if (packIds.has(pack.id)) {
            errors.push({ path: `assetPacks.${pack.id}`, message: `Duplicate asset pack ID: ${pack.id}` });
        }
        packIds.add(pack.id);
    }
    // 48. Pack label must be non-empty
    for (const pack of project.assetPacks) {
        if (!pack.label || pack.label.trim().length === 0) {
            errors.push({ path: `assetPacks.${pack.id}.label`, message: `Asset pack "${pack.id}" has empty label` });
        }
    }
    // 49. Pack version must be non-empty
    for (const pack of project.assetPacks) {
        if (!pack.version || pack.version.trim().length === 0) {
            errors.push({ path: `assetPacks.${pack.id}.version`, message: `Asset pack "${pack.id}" has empty version` });
        }
    }
    // 50. Asset packId must reference existing pack
    for (const a of project.assets) {
        if (a.packId && !packIds.has(a.packId)) {
            errors.push({ path: `assets.${a.id}.packId`, message: `Asset "${a.id}" references nonexistent pack "${a.packId}"` });
        }
    }
    // 51. Orphaned packs (no assets use this packId)
    const usedPackIds = new Set();
    for (const a of project.assets) {
        if (a.packId)
            usedPackIds.add(a.packId);
    }
    for (const pack of project.assetPacks) {
        if (!usedPackIds.has(pack.id)) {
            errors.push({ path: `assetPacks.${pack.id}`, message: `Asset pack "${pack.id}" has no assets assigned to it` });
        }
    }
    // 52. Pack version must be valid semver format (x.y.z) — uses module-level SEMVER_PATTERN
    for (const pack of project.assetPacks) {
        if (pack.version && pack.version.trim().length > 0 && !SEMVER_PATTERN.test(pack.version)) {
            errors.push({ path: `assetPacks.${pack.id}.version`, message: `Asset pack "${pack.id}" version "${pack.version}" is not valid semver (expected x.y.z)` });
        }
    }
    // 53. 2.5D elevation range sanity — floor < ceiling, both must be finite.
    // Without the finite-number guard, NaN/Infinity slip through because
    // `!(NaN < Infinity)` evaluates true and would fail silently elsewhere.
    for (const zone of project.zones) {
        if (zone.elevationRange) {
            const { floor, ceiling } = zone.elevationRange;
            if (!Number.isFinite(floor) || !Number.isFinite(ceiling)) {
                errors.push({
                    path: `zones.${zone.id}.elevationRange`,
                    message: `Zone "${zone.id}" elevationRange floor and ceiling must be finite numbers (got floor=${floor}, ceiling=${ceiling}).`,
                });
            }
            else if (!(floor < ceiling)) {
                errors.push({
                    path: `zones.${zone.id}.elevationRange`,
                    message: `Zone "${zone.id}" elevationRange requires floor (${floor}) < ceiling (${ceiling}).`,
                });
            }
        }
    }
    // 54. 2.5D parallax layer depth must be unique per zone.
    // Also validates assetRef resolves to an asset of kind 'background' or 'sprite'
    // (SCH-A-002) and that scrollFactor is finite in [0.0, 1.0] (SCH-A-003).
    for (const zone of project.zones) {
        if (!zone.parallaxLayers || zone.parallaxLayers.length === 0)
            continue;
        const seenDepths = new Map();
        const seenIds = new Set();
        for (const layer of zone.parallaxLayers) {
            // SCH-A-006: layer.id must be non-empty and not whitespace-only.
            // Checked before dedup so an empty id still errors informatively instead
            // of colliding under the dedup Set on the empty string.
            if (!layer.id || layer.id.trim().length === 0) {
                errors.push({
                    path: `zones.${zone.id}.parallaxLayers.${layer.id}`,
                    message: `Zone "${zone.id}" has a parallax layer with ${!layer.id ? 'missing' : 'whitespace-only'} id — provide a non-empty id.`,
                });
            }
            if (seenIds.has(layer.id)) {
                errors.push({
                    path: `zones.${zone.id}.parallaxLayers.${layer.id}`,
                    message: `Zone "${zone.id}" has duplicate parallax layer id "${layer.id}".`,
                });
            }
            else {
                seenIds.add(layer.id);
            }
            // SCH-A-005: layer.depth must be a finite number. Without this guard
            // NaN/Infinity slip through dedup (NaN !== NaN) and break renderer sort.
            if (!Number.isFinite(layer.depth)) {
                errors.push({
                    path: `zones.${zone.id}.parallaxLayers.${layer.id}.depth`,
                    message: `Zone "${zone.id}" parallax layer "${layer.id}" depth (${layer.depth}) must be a finite number.`,
                });
            }
            const prior = seenDepths.get(layer.depth);
            if (prior !== undefined) {
                errors.push({
                    path: `zones.${zone.id}.parallaxLayers.${layer.id}.depth`,
                    message: `Zone "${zone.id}" parallax layers "${prior}" and "${layer.id}" share depth ${layer.depth}. Depth must be unique within a zone.`,
                });
            }
            else {
                seenDepths.set(layer.depth, layer.id);
            }
            // SCH-A-002: assetRef must resolve to a 'background' or 'sprite' asset.
            // checkAssetRef only supports a single expectedKind, so we inline the
            // two-kind variant here rather than extending the helper signature.
            const refId = layer.assetRef;
            const refPath = `zones.${zone.id}.parallaxLayers.${layer.id}.assetRef`;
            const refLabel = `Zone "${zone.id}" parallax layer "${layer.id}"`;
            if (!refId) {
                errors.push({
                    path: refPath,
                    message: `${refLabel} is missing assetRef (expected a "background" or "sprite" asset).`,
                });
            }
            else if (!assetIds.has(refId)) {
                errors.push({
                    path: refPath,
                    message: `${refLabel} references nonexistent asset "${refId}" (expected a "background" or "sprite" asset). Check that the asset ID is correct and exists in the assets array.`,
                });
            }
            else {
                const asset = assetMap.get(refId);
                if (asset && asset.kind !== 'background' && asset.kind !== 'sprite') {
                    errors.push({
                        path: refPath,
                        message: `${refLabel} references asset "${refId}" of kind "${asset.kind}", expected "background" or "sprite". Assign a "background" or "sprite" asset instead.`,
                    });
                }
            }
            // SCH-A-003: scrollFactor must be finite and within [0.0, 1.0] inclusive.
            // Rejects NaN, Infinity, negatives, and values > 1.
            const sf = layer.scrollFactor;
            if (!Number.isFinite(sf) || sf < 0 || sf > 1) {
                errors.push({
                    path: `zones.${zone.id}.parallaxLayers.${layer.id}.scrollFactor`,
                    message: `Zone "${zone.id}" parallax layer "${layer.id}" scrollFactor (${sf}) must be a finite number in [0.0, 1.0].`,
                });
            }
        }
    }
    // 55. 2.5D Zone.skylineRef — if set, must resolve to a 'background' asset.
    // (SCH-A-004) Mirrors the zone backgroundId/tilesetId pattern above.
    for (const zone of project.zones) {
        checkAssetRef(zone.skylineRef, 'background', `zones.${zone.id}.skylineRef`, `Zone "${zone.id}"`);
    }
    // --- LootTable validation (SCH-FT-001) ---
    const lootTables = project.lootTables ?? [];
    const lootTableIds = new Set();
    // 56. LootTable ID uniqueness
    for (const lt of lootTables) {
        if (lootTableIds.has(lt.id)) {
            errors.push({ path: `lootTables.${lt.id}`, message: `Duplicate loot table ID: ${lt.id}` });
        }
        lootTableIds.add(lt.id);
        if (!lt.entries || lt.entries.length === 0) {
            errors.push({
                path: `lootTables.${lt.id}.entries`,
                message: `Loot table "${lt.id}" must contain at least one entry`,
            });
        }
        // 58 (rolls half): rolls >= 1 when provided.
        if (lt.rolls !== undefined) {
            if (!Number.isFinite(lt.rolls) || lt.rolls < 1) {
                errors.push({
                    path: `lootTables.${lt.id}.rolls`,
                    message: `Loot table "${lt.id}" rolls (${lt.rolls}) must be a finite number >= 1`,
                });
            }
        }
        for (let i = 0; i < (lt.entries ?? []).length; i++) {
            const entry = lt.entries[i];
            // 57. Each entry's weight must be > 0 and finite.
            if (!Number.isFinite(entry.weight) || entry.weight <= 0) {
                errors.push({
                    path: `lootTables.${lt.id}.entries[${i}]`,
                    message: `Loot table "${lt.id}" entry "${entry.itemId}" weight (${entry.weight}) must be a finite number > 0`,
                });
            }
            // 58 (quantity half): quantity.min <= quantity.max, both >= 0 and finite.
            if (entry.quantity) {
                const { min, max } = entry.quantity;
                if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max < 0) {
                    errors.push({
                        path: `lootTables.${lt.id}.entries[${i}].quantity`,
                        message: `Loot table "${lt.id}" entry "${entry.itemId}" quantity min (${min}) and max (${max}) must be finite numbers >= 0`,
                    });
                }
                else if (min > max) {
                    errors.push({
                        path: `lootTables.${lt.id}.entries[${i}].quantity`,
                        message: `Loot table "${lt.id}" entry "${entry.itemId}" quantity.min (${min}) must be <= quantity.max (${max})`,
                    });
                }
            }
            // Entry condition reuses SpawnCondition grammar.
            if (entry.condition) {
                const condErr = validateSpawnCondition(entry.condition);
                if (condErr) {
                    errors.push({
                        path: `lootTables.${lt.id}.entries[${i}].condition`,
                        message: `Loot table "${lt.id}" entry "${entry.itemId}": ${condErr}`,
                    });
                }
            }
        }
    }
    // ItemPlacement.lootTableId must reference an existing loot table when set.
    for (const ip of project.itemPlacements) {
        if (ip.lootTableId && !lootTableIds.has(ip.lootTableId)) {
            errors.push({
                path: `itemPlacements.${ip.itemId}.lootTableId`,
                message: `Item "${ip.itemId}" references nonexistent loot table "${ip.lootTableId}"`,
            });
        }
    }
    // 59. EntityPlacement.spawnCondition grammar (SCH-FT-003).
    for (const ep of project.entityPlacements) {
        const condErr = validateSpawnCondition(ep.spawnCondition);
        if (condErr) {
            errors.push({
                path: `entityPlacements.${ep.entityId}.spawnCondition`,
                message: `Entity "${ep.entityId}": ${condErr}`,
            });
        }
    }
    // --- TransitionEntity validation (SCH-FT-004) ---
    const transitions = project.transitions ?? [];
    const transitionIds = new Set();
    for (const t of transitions) {
        // 60. TransitionEntity id uniqueness.
        if (transitionIds.has(t.id)) {
            errors.push({ path: `transitions.${t.id}`, message: `Duplicate transition ID: ${t.id}` });
        }
        transitionIds.add(t.id);
        // 61. zoneId + targetZoneId must reference existing zones.
        if (!zoneIds.has(t.zoneId)) {
            errors.push({
                path: `transitions.${t.id}.zoneId`,
                message: `Transition "${t.id}" in nonexistent zone "${t.zoneId}"`,
            });
        }
        if (!zoneIds.has(t.targetZoneId)) {
            errors.push({
                path: `transitions.${t.id}.targetZoneId`,
                message: `Transition "${t.id}" targets nonexistent zone "${t.targetZoneId}"`,
            });
        }
        // 62. durationSeconds must be finite and >= 0 when present.
        if (t.durationSeconds !== undefined) {
            if (!Number.isFinite(t.durationSeconds) || t.durationSeconds < 0) {
                errors.push({
                    path: `transitions.${t.id}.durationSeconds`,
                    message: `Transition "${t.id}" durationSeconds (${t.durationSeconds}) must be a finite number >= 0`,
                });
            }
        }
    }
    // --- Zone physics + sky + collision validation (SCH-FT-006, UE-FT-002, UE-FT-003) ---
    const VALID_COLLISION_TYPES = new Set(['walkable', 'water', 'hazard', 'void', 'custom']);
    for (const zone of project.zones) {
        // 63. gravityOverride must be finite when set (can be 0 for zero-g).
        if (zone.gravityOverride !== undefined && !Number.isFinite(zone.gravityOverride)) {
            errors.push({
                path: `zones.${zone.id}.gravityOverride`,
                message: `Zone "${zone.id}" gravityOverride (${zone.gravityOverride}) must be a finite number`,
            });
        }
        // 64. Sky + lighting metadata sanity (only when set).
        if (zone.directionalLightYaw !== undefined) {
            if (!Number.isFinite(zone.directionalLightYaw) || zone.directionalLightYaw < -360 || zone.directionalLightYaw > 360) {
                errors.push({
                    path: `zones.${zone.id}.directionalLightYaw`,
                    message: `Zone "${zone.id}" directionalLightYaw (${zone.directionalLightYaw}) must be finite and within [-360, 360]`,
                });
            }
        }
        if (zone.directionalLightPitch !== undefined) {
            if (!Number.isFinite(zone.directionalLightPitch) || zone.directionalLightPitch < -90 || zone.directionalLightPitch > 90) {
                errors.push({
                    path: `zones.${zone.id}.directionalLightPitch`,
                    message: `Zone "${zone.id}" directionalLightPitch (${zone.directionalLightPitch}) must be finite and within [-90, 90]`,
                });
            }
        }
        if (zone.skyLightIntensity !== undefined) {
            if (!Number.isFinite(zone.skyLightIntensity) || zone.skyLightIntensity < 0) {
                errors.push({
                    path: `zones.${zone.id}.skyLightIntensity`,
                    message: `Zone "${zone.id}" skyLightIntensity (${zone.skyLightIntensity}) must be finite and >= 0`,
                });
            }
        }
        // 65. collisionType runtime guard (TS enforces at compile time for authored code,
        // but imported JSON can still smuggle in stray strings).
        if (zone.collisionType !== undefined && !VALID_COLLISION_TYPES.has(zone.collisionType)) {
            errors.push({
                path: `zones.${zone.id}.collisionType`,
                message: `Zone "${zone.id}" has unsupported collisionType "${zone.collisionType}" (expected one of: walkable, water, hazard, void, custom)`,
            });
        }
    }
    // Structured warning counts for callers that want a quick health check
    warningCount = errors.length;
    if (verbose && errors.length > 0) {
        // In verbose mode, callers can inspect the full errors array for detail.
        // This is useful for editor integrations that want to show inline diagnostics.
    }
    return { valid: errors.length === 0, errors, warningCount };
}
//# sourceMappingURL=validate.js.map