#!/usr/bin/env python3
"""Validate content catalogs against JSON Schema (CI + local).

Validates:
- skill catalogs: content/skills/*_skills.json rows vs content/schemas/skill-def.schema.json
- level curves: content/skills/*_level_curve.json vs content/schemas/level-curve.schema.json
- mastery catalogs: content/mastery/*_mastery.json vs content/schemas/mastery-catalog.schema.json
  (post-schema: tierIndex unique and sequential 1..N per track, aligned with server boot — NEO-46)
- item catalogs: content/items/*_items.json rows vs content/schemas/item-def.schema.json (NEO-50)
- resource node catalogs: content/resource-nodes/*_resource_nodes.json vs resource-node-def.schema.json (NEO-57)
- resource yield catalogs: content/resource-nodes/*_resource_yields.json vs resource-yield-row.schema.json (NEO-57)
- recipe catalogs: content/recipes/*_recipes.json rows vs content/schemas/recipe-def.schema.json (NEO-65)
- ability catalogs: content/abilities/*_abilities.json rows vs content/schemas/ability-def.schema.json (NEO-76)
- npc behavior catalogs: content/npc-behaviors/*_npc_behaviors.json rows vs content/schemas/npc-behavior-def.schema.json (NEO-87)
"""

from __future__ import annotations

import json
import sys
from pathlib import Path

from jsonschema import Draft202012Validator
from referencing import Registry, Resource

REPO_ROOT = Path(__file__).resolve().parent.parent
SKILL_SCHEMA = REPO_ROOT / "content/schemas/skill-def.schema.json"
LEVEL_CURVE_SCHEMA = REPO_ROOT / "content/schemas/level-curve.schema.json"
MASTERY_SCHEMA = REPO_ROOT / "content/schemas/mastery-catalog.schema.json"
ITEM_SCHEMA = REPO_ROOT / "content/schemas/item-def.schema.json"
RESOURCE_NODE_SCHEMA = REPO_ROOT / "content/schemas/resource-node-def.schema.json"
RESOURCE_YIELD_ROW_SCHEMA = REPO_ROOT / "content/schemas/resource-yield-row.schema.json"
RECIPE_IO_ROW_SCHEMA = REPO_ROOT / "content/schemas/recipe-io-row.schema.json"
RECIPE_SCHEMA = REPO_ROOT / "content/schemas/recipe-def.schema.json"
ABILITY_SCHEMA = REPO_ROOT / "content/schemas/ability-def.schema.json"
NPC_BEHAVIOR_SCHEMA = REPO_ROOT / "content/schemas/npc-behavior-def.schema.json"
SKILLS_DIR = REPO_ROOT / "content/skills"
MASTERY_DIR = REPO_ROOT / "content/mastery"
ITEMS_DIR = REPO_ROOT / "content/items"
RESOURCE_NODES_DIR = REPO_ROOT / "content/resource-nodes"
RECIPES_DIR = REPO_ROOT / "content/recipes"
ABILITIES_DIR = REPO_ROOT / "content/abilities"
NPC_BEHAVIORS_DIR = REPO_ROOT / "content/npc-behaviors"

# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})

# Slice 1 prototype lock (NEO-50): exact item ids + prototypeRole coverage after schema passes.
PROTOTYPE_SLICE1_ITEM_IDS = frozenset(
    {
        "scrap_metal_bulk",
        "refined_plate_stock",
        "field_stim_mk0",
        "survey_drone_kit",
        "contract_handoff_token",
        "prototype_armor_shell",
    }
)
PROTOTYPE_SLICE1_ITEM_ROLES = frozenset(
    {"material", "intermediate", "consumable", "utility", "quest_token", "equip_stub"}
)

# Slice 4 prototype lock (NEO-45): exactly one salvage track across all mastery files.
PROTOTYPE_SLICE4_SALVAGE_SKILL_ID = "salvage"

# Slice 2 prototype lock (NEO-57): exact nodeDefIds + gatherLens coverage after schema passes.
# Keep in sync with server loader when E3M1-02 lands.
PROTOTYPE_SLICE2_NODE_IDS = frozenset(
    {
        "prototype_resource_node_alpha",
        "prototype_subsurface_vein_beta",
        "prototype_bio_mat_gamma",
        "prototype_urban_bulk_delta",
    }
)
PROTOTYPE_SLICE2_GATHER_LENSES = frozenset(
    {"consumer_salvage", "subsurface", "bio", "urban_bulk"}
)
PROTOTYPE_SLICE2_YIELD_ITEM_IDS = frozenset({"scrap_metal_bulk"})

# Slice 3 prototype lock (NEO-65): exact recipe ids + recipeKind coverage after schema passes.
# Keep in sync with E3.M2 freeze table and future NEO-66 server loader.
PROTOTYPE_SLICE3_RECIPE_IDS = frozenset(
    {
        "refine_scrap_standard",
        "refine_scrap_efficient",
        "make_field_stim_mk0",
        "make_field_stim_batch",
        "make_prototype_armor",
        "make_armor_quick",
        "make_survey_drone_kit",
        "make_contract_token",
    }
)
PROTOTYPE_SLICE3_REFINE_SKILL_ID = "refine"

# Epic 5 Slice 1 prototype lock (NEO-76): exact ability ids after schema passes.
# Keep in sync with E5.M1 freeze table and future NEO-77 server loader.
PROTOTYPE_E5M1_ABILITY_IDS = frozenset(
    {
        "prototype_pulse",
        "prototype_guard",
        "prototype_dash",
        "prototype_burst",
    }
)

# Epic 5 Slice 2 prototype lock (NEO-87): exact npc behavior ids after schema passes.
# Keep in sync with E5.M2 freeze table and future NEO-88 server loader.
PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS = frozenset(
    {
        "prototype_melee_pressure",
        "prototype_ranged_control",
        "prototype_elite_mini_boss",
    }
)


def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, str]) -> str | None:
    """Return a human-readable error if Slice 1 contract fails, else None."""
    ids = frozenset(seen_ids.keys())
    if ids != PROTOTYPE_SLICE1_SKILL_IDS:
        return (
            "error: prototype Slice 1 expects exactly skill ids "
            f"{sorted(PROTOTYPE_SLICE1_SKILL_IDS)!r}, got {sorted(ids)!r}"
        )
    cats = set(id_to_category.values())
    if "gather" not in cats or "tech" not in cats:
        return (
            "error: prototype Slice 1 requires at least one gather and one tech skill; "
            f"categories seen: {sorted(cats)!r}"
        )
    if "process" not in cats and "make" not in cats:
        return (
            "error: prototype Slice 1 requires at least one process or make skill; "
            f"categories seen: {sorted(cats)!r}"
        )
    return None


def _tier_index_gate(rel: str, track_index: int, tier_index_values: list[int]) -> str | None:
    """Keep in sync with MasteryCatalogLoader.TryGetTierIndexGateError (NEO-46)."""
    if not tier_index_values:
        return None
    seen: set[int] = set()
    for value in tier_index_values:
        if value in seen:
            return f"error: {rel}: tracks[{track_index}] duplicate tierIndex {value}"
        seen.add(value)
    expected = set(range(1, len(tier_index_values) + 1))
    if seen != expected:
        sorted_vals = ", ".join(str(v) for v in sorted(seen))
        n = len(tier_index_values)
        return (
            f"error: {rel}: tracks[{track_index}] tierIndex values must be unique and "
            f"sequential 1..{n}, got [{sorted_vals}]"
        )
    return None


def _validate_mastery_catalogs(
    *,
    mastery_files: list[Path],
    mastery_validator: Draft202012Validator,
    skill_ids: frozenset[str],
) -> tuple[int, list[str]]:
    """Validate mastery JSON files. Returns (error_count, track_skill_ids across all files)."""
    errors = 0
    all_track_skill_ids: list[str] = []
    global_perk_ids: dict[str, str] = {}
    # Each perkId may appear in at most one branch reference catalog-wide (v1 mutually exclusive trees).
    global_referenced_perk_ids: dict[str, str] = {}

    for path in mastery_files:
        rel = str(path.relative_to(REPO_ROOT))
        data = json.loads(path.read_text(encoding="utf-8"))
        schema_errors = 0
        for err in sorted(mastery_validator.iter_errors(data), key=lambda e: e.path):
            loc = ".".join(str(p) for p in err.path) or "(root)"
            print(f"error: {rel} {loc}: {err.message}", file=sys.stderr)
            schema_errors += 1
            errors += 1

        if schema_errors > 0:
            continue

        perks = data.get("perks")
        tracks = data.get("tracks")
        if not isinstance(perks, dict) or not isinstance(tracks, list):
            print(f"error: {rel}: expected top-level 'perks' object and 'tracks' array", file=sys.stderr)
            errors += 1
            continue

        referenced_perk_ids_in_file: set[str] = set()

        for perk_key, perk in perks.items():
            if not isinstance(perk, dict):
                print(f"error: {rel}: perks[{perk_key!r}] must be an object", file=sys.stderr)
                errors += 1
                continue
            pid = perk.get("id")
            if not isinstance(pid, str):
                continue
            if perk_key != pid:
                print(
                    f"error: {rel}: perks map key {perk_key!r} must match PerkDef.id {pid!r}",
                    file=sys.stderr,
                )
                errors += 1
            prev = global_perk_ids.get(pid)
            if prev:
                print(f"error: duplicate perk id {pid!r} in {prev} and {rel}", file=sys.stderr)
                errors += 1
            else:
                global_perk_ids[pid] = rel

        for ti, track in enumerate(tracks):
            if not isinstance(track, dict):
                print(f"error: {rel}: tracks[{ti}] must be an object", file=sys.stderr)
                errors += 1
                continue

            skill_id = track.get("skillId")
            if isinstance(skill_id, str):
                all_track_skill_ids.append(skill_id)
                if skill_id not in skill_ids:
                    print(
                        f"error: {rel}: tracks[{ti}].skillId {skill_id!r} is not a known SkillDef id "
                        f"(known: {sorted(skill_ids)!r})",
                        file=sys.stderr,
                    )
                    errors += 1

            tiers = track.get("tiers")
            if not isinstance(tiers, list):
                continue

            prev_required_level = 0
            prev_tier_branch_ids: frozenset[str] | None = None
            tier_index_values: list[int] = []

            for tier_i, tier in enumerate(tiers):
                if not isinstance(tier, dict):
                    continue

                tier_index = tier.get("tierIndex")
                if isinstance(tier_index, int):
                    tier_index_values.append(tier_index)

                required_level = tier.get("requiredLevel")
                if isinstance(required_level, int) and required_level <= prev_required_level:
                    print(
                        f"error: {rel}: tracks[{ti}].tiers[{tier_i}].requiredLevel must be strictly "
                        f"greater than previous tier ({prev_required_level})",
                        file=sys.stderr,
                    )
                    errors += 1
                if isinstance(required_level, int):
                    prev_required_level = required_level

                branches = tier.get("branches")
                if not isinstance(branches, list):
                    continue

                tier_branch_ids: list[str] = []
                for bi, branch in enumerate(branches):
                    if not isinstance(branch, dict):
                        continue
                    branch_id = branch.get("branchId")
                    if isinstance(branch_id, str):
                        if branch_id in tier_branch_ids:
                            print(
                                f"error: {rel}: tracks[{ti}].tiers[{tier_i}] duplicate branchId "
                                f"{branch_id!r}",
                                file=sys.stderr,
                            )
                            errors += 1
                        tier_branch_ids.append(branch_id)

                    perk_ids = branch.get("perkIds")
                    if not isinstance(perk_ids, list):
                        continue
                    for perk_id in perk_ids:
                        if not isinstance(perk_id, str):
                            continue
                        if perk_id not in perks:
                            print(
                                f"error: {rel}: tracks[{ti}].tiers[{tier_i}].branches[{bi}] "
                                f"references unknown perkId {perk_id!r}",
                                file=sys.stderr,
                            )
                            errors += 1
                        prev_ref = global_referenced_perk_ids.get(perk_id)
                        if prev_ref:
                            print(
                                f"error: duplicate perk id reference {perk_id!r} in {prev_ref} and {rel}",
                                file=sys.stderr,
                            )
                            errors += 1
                        else:
                            global_referenced_perk_ids[perk_id] = (
                                f"{rel} tracks[{ti}].tiers[{tier_i}].branches[{bi}]"
                            )
                        referenced_perk_ids_in_file.add(perk_id)

                tier_branch_set = frozenset(tier_branch_ids)
                if prev_tier_branch_ids is not None and tier_branch_set != prev_tier_branch_ids:
                    print(
                        f"error: {rel}: tracks[{ti}].tiers[{tier_i}] branchId set "
                        f"{sorted(tier_branch_set)!r} must match previous tier "
                        f"{sorted(prev_tier_branch_ids)!r}",
                        file=sys.stderr,
                    )
                    errors += 1
                if tier_branch_ids:
                    prev_tier_branch_ids = tier_branch_set

            tier_index_err = _tier_index_gate(rel, ti, tier_index_values)
            if tier_index_err:
                print(tier_index_err, file=sys.stderr)
                errors += 1

        for perk_key in perks:
            if isinstance(perk_key, str) and perk_key not in referenced_perk_ids_in_file:
                print(
                    f"error: {rel}: perks[{perk_key!r}] is not referenced by any branch perkIds",
                    file=sys.stderr,
                )
                errors += 1

    return errors, all_track_skill_ids


def _prototype_slice1_item_gate(
    seen_ids: dict[str, str],
    id_to_role: dict[str, str],
    id_to_slot_kind: dict[str, str],
    id_to_stack_max: dict[str, int],
) -> str | None:
    """Return a human-readable error if Slice 1 item contract fails, else None."""
    ids = frozenset(seen_ids.keys())
    if ids != PROTOTYPE_SLICE1_ITEM_IDS:
        return (
            "error: prototype Slice 1 expects exactly item ids "
            f"{sorted(PROTOTYPE_SLICE1_ITEM_IDS)!r}, got {sorted(ids)!r}"
        )
    roles = set(id_to_role.values())
    if roles != PROTOTYPE_SLICE1_ITEM_ROLES:
        return (
            "error: prototype Slice 1 requires exactly one row per prototypeRole "
            f"{sorted(PROTOTYPE_SLICE1_ITEM_ROLES)!r}, roles seen: {sorted(roles)!r}"
        )
    equip_stub_id = next((iid for iid, role in id_to_role.items() if role == "equip_stub"), None)
    if equip_stub_id is not None:
        if id_to_slot_kind.get(equip_stub_id) != "equipment":
            return (
                f"error: {equip_stub_id!r} (equip_stub) must use inventorySlotKind 'equipment', "
                f"got {id_to_slot_kind.get(equip_stub_id)!r}"
            )
        if id_to_stack_max.get(equip_stub_id) != 1:
            return (
                f"error: {equip_stub_id!r} (equip_stub) must use stackMax 1, "
                f"got {id_to_stack_max.get(equip_stub_id)!r}"
            )
    return None


def _validate_item_catalogs(
    *,
    item_files: list[Path],
    item_validator: Draft202012Validator,
) -> tuple[int, dict[str, str], dict[str, str], dict[str, str], dict[str, int]]:
    """Validate item JSON files. Returns (error_count, seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max)."""
    errors = 0
    seen_ids: dict[str, str] = {}
    id_to_role: dict[str, str] = {}
    id_to_slot_kind: dict[str, str] = {}
    id_to_stack_max: dict[str, int] = {}

    for path in item_files:
        rel = str(path.relative_to(REPO_ROOT))
        data = json.loads(path.read_text(encoding="utf-8"))
        schema_version = data.get("schemaVersion")
        if schema_version != 1:
            print(f"error: {rel}: expected schemaVersion 1, got {schema_version!r}", file=sys.stderr)
            errors += 1
            continue

        items = data.get("items")
        if not isinstance(items, list):
            print(f"error: {rel}: expected top-level 'items' array", file=sys.stderr)
            errors += 1
            continue

        for i, row in enumerate(items):
            if not isinstance(row, dict):
                print(f"error: {rel}: items[{i}] must be an object", file=sys.stderr)
                errors += 1
                continue
            row_schema_errors = 0
            for err in sorted(item_validator.iter_errors(row), key=lambda e: e.path):
                loc = ".".join(str(p) for p in err.path) or "(root)"
                print(f"error: {rel} items[{i}] {loc}: {err.message}", file=sys.stderr)
                row_schema_errors += 1
                errors += 1
            iid = row.get("id")
            if isinstance(iid, str) and row_schema_errors == 0:
                prev = seen_ids.get(iid)
                if prev:
                    print(f"error: duplicate item id {iid!r} in {prev} and {rel}", file=sys.stderr)
                    errors += 1
                else:
                    seen_ids[iid] = rel
                    role = row.get("prototypeRole")
                    if isinstance(role, str):
                        id_to_role[iid] = role
                    slot_kind = row.get("inventorySlotKind")
                    if isinstance(slot_kind, str):
                        id_to_slot_kind[iid] = slot_kind
                    stack_max = row.get("stackMax")
                    if isinstance(stack_max, int):
                        id_to_stack_max[iid] = stack_max

    return errors, seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max


def _validate_ability_catalogs(
    *,
    ability_files: list[Path],
    ability_validator: Draft202012Validator,
) -> tuple[int, dict[str, str]]:
    """Validate ability JSON files. Returns (error_count, seen_ids)."""
    errors = 0
    seen_ids: dict[str, str] = {}

    for path in ability_files:
        rel = str(path.relative_to(REPO_ROOT))
        data = json.loads(path.read_text(encoding="utf-8"))
        schema_version = data.get("schemaVersion")
        if schema_version != 1:
            print(f"error: {rel}: expected schemaVersion 1, got {schema_version!r}", file=sys.stderr)
            errors += 1
            continue

        abilities = data.get("abilities")
        if not isinstance(abilities, list):
            print(f"error: {rel}: expected top-level 'abilities' array", file=sys.stderr)
            errors += 1
            continue

        for i, row in enumerate(abilities):
            if not isinstance(row, dict):
                print(f"error: {rel}: abilities[{i}] must be an object", file=sys.stderr)
                errors += 1
                continue
            row_schema_errors = 0
            for err in sorted(ability_validator.iter_errors(row), key=lambda e: e.path):
                loc = ".".join(str(p) for p in err.path) or "(root)"
                print(f"error: {rel} abilities[{i}] {loc}: {err.message}", file=sys.stderr)
                row_schema_errors += 1
                errors += 1
            aid = row.get("id")
            if isinstance(aid, str) and row_schema_errors == 0:
                prev = seen_ids.get(aid)
                if prev:
                    print(f"error: duplicate ability id {aid!r} in {prev} and {rel}", file=sys.stderr)
                    errors += 1
                else:
                    seen_ids[aid] = rel

    return errors, seen_ids


def _prototype_e5m1_ability_gate(seen_ids: dict[str, str]) -> str | None:
    """Return a human-readable error if E5M1 ability contract fails, else None."""
    ids = frozenset(seen_ids.keys())
    if ids != PROTOTYPE_E5M1_ABILITY_IDS:
        return (
            "error: prototype E5M1 expects exactly ability ids "
            f"{sorted(PROTOTYPE_E5M1_ABILITY_IDS)!r}, got {sorted(ids)!r}"
        )
    return None


def _validate_npc_behavior_catalogs(
    *,
    npc_behavior_files: list[Path],
    npc_behavior_validator: Draft202012Validator,
) -> tuple[int, dict[str, str], dict[str, dict]]:
    """Validate NPC behavior JSON files. Returns (error_count, seen_ids, id_to_row)."""
    errors = 0
    seen_ids: dict[str, str] = {}
    id_to_row: dict[str, dict] = {}

    for path in npc_behavior_files:
        rel = str(path.relative_to(REPO_ROOT))
        data = json.loads(path.read_text(encoding="utf-8"))
        schema_version = data.get("schemaVersion")
        if schema_version != 1:
            print(f"error: {rel}: expected schemaVersion 1, got {schema_version!r}", file=sys.stderr)
            errors += 1
            continue

        npc_behaviors = data.get("npcBehaviors")
        if not isinstance(npc_behaviors, list):
            print(f"error: {rel}: expected top-level 'npcBehaviors' array", file=sys.stderr)
            errors += 1
            continue

        for i, row in enumerate(npc_behaviors):
            if not isinstance(row, dict):
                print(f"error: {rel}: npcBehaviors[{i}] must be an object", file=sys.stderr)
                errors += 1
                continue
            row_schema_errors = 0
            for err in sorted(npc_behavior_validator.iter_errors(row), key=lambda e: e.path):
                loc = ".".join(str(p) for p in err.path) or "(root)"
                print(f"error: {rel} npcBehaviors[{i}] {loc}: {err.message}", file=sys.stderr)
                row_schema_errors += 1
                errors += 1
            bid = row.get("id")
            if isinstance(bid, str) and row_schema_errors == 0:
                prev = seen_ids.get(bid)
                if prev:
                    print(f"error: duplicate npc behavior id {bid!r} in {prev} and {rel}", file=sys.stderr)
                    errors += 1
                else:
                    seen_ids[bid] = rel
                    id_to_row[bid] = row

    return errors, seen_ids, id_to_row


def _prototype_e5m2_npc_behavior_gate(seen_ids: dict[str, str]) -> str | None:
    """Return a human-readable error if E5M2 npc behavior contract fails, else None."""
    ids = frozenset(seen_ids.keys())
    if ids != PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS:
        return (
            "error: prototype E5M2 expects exactly npc behavior ids "
            f"{sorted(PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS)!r}, got {sorted(ids)!r}"
        )
    return None


def _prototype_e5m2_npc_behavior_numeric_gate(id_to_row: dict[str, dict]) -> str | None:
    """Return a human-readable error if leash/aggro invariants fail, else None."""
    for bid, row in id_to_row.items():
        aggro = row.get("aggroRadius")
        leash = row.get("leashRadius")
        if isinstance(aggro, (int, float)) and isinstance(leash, (int, float)):
            if not leash > aggro:
                return (
                    f"error: npc behavior {bid!r}: leashRadius {leash} must be > aggroRadius {aggro}"
                )
    return None


def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None:
    """Return a human-readable error if Slice 4 contract fails, else None."""
    if len(track_skill_ids) != 1:
        return (
            "error: prototype Slice 4 expects exactly one MasteryTrack across all mastery files, "
            f"got {len(track_skill_ids)} track(s) with skillIds {track_skill_ids!r}"
        )
    if track_skill_ids[0] != PROTOTYPE_SLICE4_SALVAGE_SKILL_ID:
        return (
            "error: prototype Slice 4 expects the sole track skillId "
            f"{PROTOTYPE_SLICE4_SALVAGE_SKILL_ID!r}, got {track_skill_ids[0]!r}"
        )
    return None


def _prototype_slice2_resource_node_gate(
    seen_ids: dict[str, str],
    id_to_lens: dict[str, str],
) -> str | None:
    """Return a human-readable error if Slice 2 resource-node contract fails, else None."""
    ids = frozenset(seen_ids.keys())
    if ids != PROTOTYPE_SLICE2_NODE_IDS:
        return (
            "error: prototype Slice 2 expects exactly nodeDefIds "
            f"{sorted(PROTOTYPE_SLICE2_NODE_IDS)!r}, got {sorted(ids)!r}"
        )
    lenses = set(id_to_lens.values())
    if lenses != PROTOTYPE_SLICE2_GATHER_LENSES:
        return (
            "error: prototype Slice 2 requires exactly one row per gatherLens "
            f"{sorted(PROTOTYPE_SLICE2_GATHER_LENSES)!r}, lenses seen: {sorted(lenses)!r}"
        )
    return None


def _prototype_slice2_yield_gate(
    yield_node_ids: frozenset[str],
    yield_item_ids: frozenset[str],
) -> str | None:
    """Return a human-readable error if Slice 2 yield contract fails, else None."""
    if yield_node_ids != PROTOTYPE_SLICE2_NODE_IDS:
        return (
            "error: prototype Slice 2 expects exactly one yield row per nodeDefId "
            f"{sorted(PROTOTYPE_SLICE2_NODE_IDS)!r}, yield nodeDefIds: {sorted(yield_node_ids)!r}"
        )
    if yield_item_ids != PROTOTYPE_SLICE2_YIELD_ITEM_IDS:
        return (
            "error: prototype Slice 2 expects yield itemIds "
            f"{sorted(PROTOTYPE_SLICE2_YIELD_ITEM_IDS)!r} only, got {sorted(yield_item_ids)!r}"
        )
    return None


def _validate_resource_node_catalogs(
    *,
    node_files: list[Path],
    node_validator: Draft202012Validator,
) -> tuple[int, dict[str, str], dict[str, str]]:
    """Validate resource node JSON files. Returns (error_count, seen_ids, id_to_lens)."""
    errors = 0
    seen_ids: dict[str, str] = {}
    id_to_lens: dict[str, str] = {}

    for path in node_files:
        rel = str(path.relative_to(REPO_ROOT))
        data = json.loads(path.read_text(encoding="utf-8"))
        schema_version = data.get("schemaVersion")
        if schema_version != 1:
            print(f"error: {rel}: expected schemaVersion 1, got {schema_version!r}", file=sys.stderr)
            errors += 1
            continue

        nodes = data.get("nodes")
        if not isinstance(nodes, list):
            print(f"error: {rel}: expected top-level 'nodes' array", file=sys.stderr)
            errors += 1
            continue

        for i, row in enumerate(nodes):
            if not isinstance(row, dict):
                print(f"error: {rel}: nodes[{i}] must be an object", file=sys.stderr)
                errors += 1
                continue
            row_schema_errors = 0
            for err in sorted(node_validator.iter_errors(row), key=lambda e: e.path):
                loc = ".".join(str(p) for p in err.path) or "(root)"
                print(f"error: {rel} nodes[{i}] {loc}: {err.message}", file=sys.stderr)
                row_schema_errors += 1
                errors += 1
            nid = row.get("nodeDefId")
            if isinstance(nid, str) and row_schema_errors == 0:
                prev = seen_ids.get(nid)
                if prev:
                    print(
                        f"error: duplicate nodeDefId {nid!r} in {prev} and {rel}",
                        file=sys.stderr,
                    )
                    errors += 1
                else:
                    seen_ids[nid] = rel
                    gather_lens = row.get("gatherLens")
                    if isinstance(gather_lens, str):
                        id_to_lens[nid] = gather_lens

    return errors, seen_ids, id_to_lens


def _validate_resource_yield_catalogs(
    *,
    yield_files: list[Path],
    yield_validator: Draft202012Validator,
    known_node_ids: frozenset[str],
    known_item_ids: frozenset[str],
) -> tuple[int, frozenset[str], frozenset[str]]:
    """Validate yield JSON files. Returns (error_count, yield_node_ids, yield_item_ids)."""
    errors = 0
    seen_node_ids: dict[str, str] = {}
    yield_item_ids: set[str] = set()

    for path in yield_files:
        rel = str(path.relative_to(REPO_ROOT))
        data = json.loads(path.read_text(encoding="utf-8"))
        schema_version = data.get("schemaVersion")
        if schema_version != 1:
            print(f"error: {rel}: expected schemaVersion 1, got {schema_version!r}", file=sys.stderr)
            errors += 1
            continue

        yields = data.get("yields")
        if not isinstance(yields, list):
            print(f"error: {rel}: expected top-level 'yields' array", file=sys.stderr)
            errors += 1
            continue

        for i, row in enumerate(yields):
            if not isinstance(row, dict):
                print(f"error: {rel}: yields[{i}] must be an object", file=sys.stderr)
                errors += 1
                continue
            row_schema_errors = 0
            for err in sorted(yield_validator.iter_errors(row), key=lambda e: e.path):
                loc = ".".join(str(p) for p in err.path) or "(root)"
                print(f"error: {rel} yields[{i}] {loc}: {err.message}", file=sys.stderr)
                row_schema_errors += 1
                errors += 1
            nid = row.get("nodeDefId")
            item_id = row.get("itemId")
            if isinstance(nid, str) and row_schema_errors == 0:
                prev = seen_node_ids.get(nid)
                if prev:
                    print(
                        f"error: duplicate yield nodeDefId {nid!r} in {prev} and {rel}",
                        file=sys.stderr,
                    )
                    errors += 1
                else:
                    seen_node_ids[nid] = rel
                    if nid not in known_node_ids:
                        print(
                            f"error: {rel} yields[{i}]: nodeDefId {nid!r} is not defined in resource node catalogs",
                            file=sys.stderr,
                        )
                        errors += 1
            if isinstance(item_id, str) and row_schema_errors == 0:
                yield_item_ids.add(item_id)
                if item_id not in known_item_ids:
                    print(
                        f"error: {rel} yields[{i}]: itemId {item_id!r} is not in item catalogs",
                        file=sys.stderr,
                    )
                    errors += 1

    return errors, frozenset(seen_node_ids.keys()), frozenset(yield_item_ids)


def _recipe_def_validator() -> Draft202012Validator:
    """Build a validator that resolves recipe-def $ref to recipe-io-row schema."""
    io_schema = json.loads(RECIPE_IO_ROW_SCHEMA.read_text(encoding="utf-8"))
    def_schema = json.loads(RECIPE_SCHEMA.read_text(encoding="utf-8"))
    registry = Registry().with_resources(
        [
            (def_schema["$id"], Resource.from_contents(def_schema)),
            (io_schema["$id"], Resource.from_contents(io_schema)),
        ]
    )
    return Draft202012Validator(def_schema, registry=registry)


def _prototype_slice3_recipe_gate(
    seen_ids: dict[str, str],
    id_to_kind: dict[str, str],
    id_to_skill: dict[str, str],
) -> str | None:
    """Return a human-readable error if Slice 3 recipe contract fails, else None."""
    ids = frozenset(seen_ids.keys())
    if ids != PROTOTYPE_SLICE3_RECIPE_IDS:
        return (
            "error: prototype Slice 3 expects exactly recipe ids "
            f"{sorted(PROTOTYPE_SLICE3_RECIPE_IDS)!r}, got {sorted(ids)!r}"
        )
    kinds = set(id_to_kind.values())
    if "process" not in kinds or "make" not in kinds:
        return (
            "error: prototype Slice 3 requires at least one process and one make recipe; "
            f"recipeKind values seen: {sorted(kinds)!r}"
        )
    for rid, skill_id in id_to_skill.items():
        if skill_id != PROTOTYPE_SLICE3_REFINE_SKILL_ID:
            return (
                f"error: {rid!r} requiredSkillId must be {PROTOTYPE_SLICE3_REFINE_SKILL_ID!r}, "
                f"got {skill_id!r}"
            )
    return None


def _validate_recipe_catalogs(
    *,
    recipe_files: list[Path],
    recipe_validator: Draft202012Validator,
    known_item_ids: frozenset[str],
    known_skill_ids: frozenset[str],
) -> tuple[int, dict[str, str], dict[str, str], dict[str, str]]:
    """Validate recipe JSON files. Returns (error_count, seen_ids, id_to_kind, id_to_skill)."""
    errors = 0
    seen_ids: dict[str, str] = {}
    id_to_kind: dict[str, str] = {}
    id_to_skill: dict[str, str] = {}

    for path in recipe_files:
        rel = str(path.relative_to(REPO_ROOT))
        data = json.loads(path.read_text(encoding="utf-8"))
        schema_version = data.get("schemaVersion")
        if schema_version != 1:
            print(f"error: {rel}: expected schemaVersion 1, got {schema_version!r}", file=sys.stderr)
            errors += 1
            continue

        recipes = data.get("recipes")
        if not isinstance(recipes, list):
            print(f"error: {rel}: expected top-level 'recipes' array", file=sys.stderr)
            errors += 1
            continue

        for i, row in enumerate(recipes):
            if not isinstance(row, dict):
                print(f"error: {rel}: recipes[{i}] must be an object", file=sys.stderr)
                errors += 1
                continue
            row_schema_errors = 0
            for err in sorted(recipe_validator.iter_errors(row), key=lambda e: e.path):
                loc = ".".join(str(p) for p in err.path) or "(root)"
                print(f"error: {rel} recipes[{i}] {loc}: {err.message}", file=sys.stderr)
                row_schema_errors += 1
                errors += 1
            rid = row.get("id")
            if isinstance(rid, str) and row_schema_errors == 0:
                prev = seen_ids.get(rid)
                if prev:
                    print(f"error: duplicate recipe id {rid!r} in {prev} and {rel}", file=sys.stderr)
                    errors += 1
                else:
                    seen_ids[rid] = rel
                    recipe_kind = row.get("recipeKind")
                    if isinstance(recipe_kind, str):
                        id_to_kind[rid] = recipe_kind
                    skill_id = row.get("requiredSkillId")
                    if isinstance(skill_id, str):
                        id_to_skill[rid] = skill_id
                        if skill_id not in known_skill_ids:
                            print(
                                f"error: {rel} recipes[{i}]: requiredSkillId {skill_id!r} "
                                f"is not a known SkillDef id (known: {sorted(known_skill_ids)!r})",
                                file=sys.stderr,
                            )
                            errors += 1

                for io_key in ("inputs", "outputs"):
                    io_rows = row.get(io_key)
                    if not isinstance(io_rows, list):
                        continue
                    for j, io_row in enumerate(io_rows):
                        if not isinstance(io_row, dict):
                            continue
                        item_id = io_row.get("itemId")
                        if isinstance(item_id, str) and item_id not in known_item_ids:
                            print(
                                f"error: {rel} recipes[{i}].{io_key}[{j}]: itemId {item_id!r} "
                                "is not in item catalogs",
                                file=sys.stderr,
                            )
                            errors += 1

    return errors, seen_ids, id_to_kind, id_to_skill


def main() -> int:
    if not SKILL_SCHEMA.is_file():
        print(f"error: missing schema {SKILL_SCHEMA}", file=sys.stderr)
        return 1
    if not LEVEL_CURVE_SCHEMA.is_file():
        print(f"error: missing schema {LEVEL_CURVE_SCHEMA}", file=sys.stderr)
        return 1
    if not MASTERY_SCHEMA.is_file():
        print(f"error: missing schema {MASTERY_SCHEMA}", file=sys.stderr)
        return 1
    if not ITEM_SCHEMA.is_file():
        print(f"error: missing schema {ITEM_SCHEMA}", file=sys.stderr)
        return 1
    if not RESOURCE_NODE_SCHEMA.is_file():
        print(f"error: missing schema {RESOURCE_NODE_SCHEMA}", file=sys.stderr)
        return 1
    if not RESOURCE_YIELD_ROW_SCHEMA.is_file():
        print(f"error: missing schema {RESOURCE_YIELD_ROW_SCHEMA}", file=sys.stderr)
        return 1
    if not RECIPE_IO_ROW_SCHEMA.is_file():
        print(f"error: missing schema {RECIPE_IO_ROW_SCHEMA}", file=sys.stderr)
        return 1
    if not RECIPE_SCHEMA.is_file():
        print(f"error: missing schema {RECIPE_SCHEMA}", file=sys.stderr)
        return 1
    if not ABILITY_SCHEMA.is_file():
        print(f"error: missing schema {ABILITY_SCHEMA}", file=sys.stderr)
        return 1
    if not NPC_BEHAVIOR_SCHEMA.is_file():
        print(f"error: missing schema {NPC_BEHAVIOR_SCHEMA}", file=sys.stderr)
        return 1

    skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
    skill_validator = Draft202012Validator(skill_schema)
    level_curve_schema = json.loads(LEVEL_CURVE_SCHEMA.read_text(encoding="utf-8"))
    level_curve_validator = Draft202012Validator(level_curve_schema)
    mastery_schema = json.loads(MASTERY_SCHEMA.read_text(encoding="utf-8"))
    mastery_validator = Draft202012Validator(mastery_schema)
    item_schema = json.loads(ITEM_SCHEMA.read_text(encoding="utf-8"))
    item_validator = Draft202012Validator(item_schema)
    resource_node_schema = json.loads(RESOURCE_NODE_SCHEMA.read_text(encoding="utf-8"))
    resource_node_validator = Draft202012Validator(resource_node_schema)
    resource_yield_schema = json.loads(RESOURCE_YIELD_ROW_SCHEMA.read_text(encoding="utf-8"))
    resource_yield_validator = Draft202012Validator(resource_yield_schema)
    recipe_validator = _recipe_def_validator()
    ability_schema = json.loads(ABILITY_SCHEMA.read_text(encoding="utf-8"))
    ability_validator = Draft202012Validator(ability_schema)
    npc_behavior_schema = json.loads(NPC_BEHAVIOR_SCHEMA.read_text(encoding="utf-8"))
    npc_behavior_validator = Draft202012Validator(npc_behavior_schema)

    if not SKILLS_DIR.is_dir():
        print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr)
        return 1

    skill_files = sorted(SKILLS_DIR.glob("*_skills.json"))
    if not skill_files:
        print(f"error: no *_skills.json files under {SKILLS_DIR}", file=sys.stderr)
        return 1

    curve_files = sorted(SKILLS_DIR.glob("*_level_curve.json"))
    if not curve_files:
        print(f"error: no *_level_curve.json files under {SKILLS_DIR}", file=sys.stderr)
        return 1

    if not MASTERY_DIR.is_dir():
        print(f"error: missing directory {MASTERY_DIR}", file=sys.stderr)
        return 1

    mastery_files = sorted(MASTERY_DIR.glob("*_mastery.json"))
    if not mastery_files:
        print(f"error: no *_mastery.json files under {MASTERY_DIR}", file=sys.stderr)
        return 1

    if not ITEMS_DIR.is_dir():
        print(f"error: missing directory {ITEMS_DIR}", file=sys.stderr)
        return 1

    item_files = sorted(ITEMS_DIR.glob("*_items.json"))
    if not item_files:
        print(f"error: no *_items.json files under {ITEMS_DIR}", file=sys.stderr)
        return 1

    if not RESOURCE_NODES_DIR.is_dir():
        print(f"error: missing directory {RESOURCE_NODES_DIR}", file=sys.stderr)
        return 1

    node_files = sorted(RESOURCE_NODES_DIR.glob("*_resource_nodes.json"))
    if not node_files:
        print(f"error: no *_resource_nodes.json files under {RESOURCE_NODES_DIR}", file=sys.stderr)
        return 1

    yield_files = sorted(RESOURCE_NODES_DIR.glob("*_resource_yields.json"))
    if not yield_files:
        print(f"error: no *_resource_yields.json files under {RESOURCE_NODES_DIR}", file=sys.stderr)
        return 1

    if not RECIPES_DIR.is_dir():
        print(f"error: missing directory {RECIPES_DIR}", file=sys.stderr)
        return 1

    recipe_files = sorted(RECIPES_DIR.glob("*_recipes.json"))
    if not recipe_files:
        print(f"error: no *_recipes.json files under {RECIPES_DIR}", file=sys.stderr)
        return 1

    if not ABILITIES_DIR.is_dir():
        print(f"error: missing directory {ABILITIES_DIR}", file=sys.stderr)
        return 1

    ability_files = sorted(ABILITIES_DIR.glob("*_abilities.json"))
    if not ability_files:
        print(f"error: no *_abilities.json files under {ABILITIES_DIR}", file=sys.stderr)
        return 1

    if not NPC_BEHAVIORS_DIR.is_dir():
        print(f"error: missing directory {NPC_BEHAVIORS_DIR}", file=sys.stderr)
        return 1

    npc_behavior_files = sorted(NPC_BEHAVIORS_DIR.glob("*_npc_behaviors.json"))
    if not npc_behavior_files:
        print(f"error: no *_npc_behaviors.json files under {NPC_BEHAVIORS_DIR}", file=sys.stderr)
        return 1

    seen_ids: dict[str, str] = {}
    id_to_category: dict[str, str] = {}
    errors = 0

    for path in skill_files:
        data = json.loads(path.read_text(encoding="utf-8"))
        skills = data.get("skills")
        if not isinstance(skills, list):
            print(f"error: {path.relative_to(REPO_ROOT)}: expected top-level 'skills' array", file=sys.stderr)
            errors += 1
            continue
        for i, row in enumerate(skills):
            if not isinstance(row, dict):
                print(f"error: {path.relative_to(REPO_ROOT)}: skills[{i}] must be an object", file=sys.stderr)
                errors += 1
                continue
            row_schema_errors = 0
            for err in sorted(skill_validator.iter_errors(row), key=lambda e: e.path):
                loc = ".".join(str(p) for p in err.path) or "(root)"
                print(
                    f"error: {path.relative_to(REPO_ROOT)} skills[{i}] {loc}: {err.message}",
                    file=sys.stderr,
                )
                row_schema_errors += 1
                errors += 1
            sid = row.get("id")
            # Reserve seen_ids (and duplicate detection) only for schema-clean rows so
            # fixing a broken row does not surface a spurious duplicate vs an earlier invalid row.
            if isinstance(sid, str) and row_schema_errors == 0:
                prev = seen_ids.get(sid)
                if prev:
                    print(
                        f"error: duplicate skill id {sid!r} in {prev} and {path.relative_to(REPO_ROOT)}",
                        file=sys.stderr,
                    )
                    errors += 1
                else:
                    seen_ids[sid] = str(path.relative_to(REPO_ROOT))
                    cat = row.get("category")
                    if isinstance(cat, str):
                        id_to_category[sid] = cat

    for path in curve_files:
        data = json.loads(path.read_text(encoding="utf-8"))
        row_errors = 0
        for err in sorted(level_curve_validator.iter_errors(data), key=lambda e: e.path):
            loc = ".".join(str(p) for p in err.path) or "(root)"
            print(
                f"error: {path.relative_to(REPO_ROOT)} {loc}: {err.message}",
                file=sys.stderr,
            )
            row_errors += 1
            errors += 1

        if row_errors > 0:
            continue

        levels = data.get("levels")
        if not isinstance(levels, list):
            print(f"error: {path.relative_to(REPO_ROOT)}: expected top-level 'levels' array", file=sys.stderr)
            errors += 1
            continue

        if len(levels) == 0:
            print(f"error: {path.relative_to(REPO_ROOT)}: levels must contain at least one row", file=sys.stderr)
            errors += 1
            continue

        first = levels[0]
        if not isinstance(first, dict) or first.get("level") != 1 or first.get("requiredXp") != 0:
            print(
                f"error: {path.relative_to(REPO_ROOT)}: first row must be level=1 and requiredXp=0",
                file=sys.stderr,
            )
            errors += 1

        prev_level = 0
        prev_xp = -1
        for i, row in enumerate(levels):
            if not isinstance(row, dict):
                print(f"error: {path.relative_to(REPO_ROOT)}: levels[{i}] must be an object", file=sys.stderr)
                errors += 1
                continue
            level = row.get("level")
            required_xp = row.get("requiredXp")
            if not isinstance(level, int) or not isinstance(required_xp, int):
                print(
                    f"error: {path.relative_to(REPO_ROOT)}: levels[{i}] level/requiredXp must be integers",
                    file=sys.stderr,
                )
                errors += 1
                continue
            if level <= prev_level:
                print(
                    f"error: {path.relative_to(REPO_ROOT)}: levels[{i}].level must be strictly increasing",
                    file=sys.stderr,
                )
                errors += 1
            if required_xp <= prev_xp:
                print(
                    f"error: {path.relative_to(REPO_ROOT)}: levels[{i}].requiredXp must be strictly increasing",
                    file=sys.stderr,
                )
                errors += 1
            prev_level = level
            prev_xp = required_xp

    if errors:
        print(f"content validation failed with {errors} error(s)", file=sys.stderr)
        return 1

    slice1_err = _prototype_slice1_gate(seen_ids, id_to_category)
    if slice1_err:
        print(slice1_err, file=sys.stderr)
        return 1

    mastery_errors, track_skill_ids = _validate_mastery_catalogs(
        mastery_files=mastery_files,
        mastery_validator=mastery_validator,
        skill_ids=frozenset(seen_ids.keys()),
    )
    if mastery_errors:
        print(f"content validation failed with {mastery_errors} error(s)", file=sys.stderr)
        return 1

    slice4_err = _prototype_slice4_gate(track_skill_ids)
    if slice4_err:
        print(slice4_err, file=sys.stderr)
        return 1

    item_errors, item_seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max = _validate_item_catalogs(
        item_files=item_files,
        item_validator=item_validator,
    )
    if item_errors:
        print(f"content validation failed with {item_errors} error(s)", file=sys.stderr)
        return 1

    slice1_item_err = _prototype_slice1_item_gate(
        item_seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max
    )
    if slice1_item_err:
        print(slice1_item_err, file=sys.stderr)
        return 1

    node_errors, node_seen_ids, id_to_lens = _validate_resource_node_catalogs(
        node_files=node_files,
        node_validator=resource_node_validator,
    )
    if node_errors:
        print(f"content validation failed with {node_errors} error(s)", file=sys.stderr)
        return 1

    slice2_node_err = _prototype_slice2_resource_node_gate(node_seen_ids, id_to_lens)
    if slice2_node_err:
        print(slice2_node_err, file=sys.stderr)
        return 1

    yield_errors, yield_node_ids, yield_item_ids = _validate_resource_yield_catalogs(
        yield_files=yield_files,
        yield_validator=resource_yield_validator,
        known_node_ids=frozenset(node_seen_ids.keys()),
        known_item_ids=frozenset(item_seen_ids.keys()),
    )
    if yield_errors:
        print(f"content validation failed with {yield_errors} error(s)", file=sys.stderr)
        return 1

    slice2_yield_err = _prototype_slice2_yield_gate(yield_node_ids, yield_item_ids)
    if slice2_yield_err:
        print(slice2_yield_err, file=sys.stderr)
        return 1

    recipe_errors, recipe_seen_ids, id_to_kind, id_to_skill = _validate_recipe_catalogs(
        recipe_files=recipe_files,
        recipe_validator=recipe_validator,
        known_item_ids=frozenset(item_seen_ids.keys()),
        known_skill_ids=frozenset(seen_ids.keys()),
    )
    if recipe_errors:
        print(f"content validation failed with {recipe_errors} error(s)", file=sys.stderr)
        return 1

    slice3_recipe_err = _prototype_slice3_recipe_gate(recipe_seen_ids, id_to_kind, id_to_skill)
    if slice3_recipe_err:
        print(slice3_recipe_err, file=sys.stderr)
        return 1

    ability_errors, ability_seen_ids = _validate_ability_catalogs(
        ability_files=ability_files,
        ability_validator=ability_validator,
    )
    if ability_errors:
        print(f"content validation failed with {ability_errors} error(s)", file=sys.stderr)
        return 1

    e5m1_ability_err = _prototype_e5m1_ability_gate(ability_seen_ids)
    if e5m1_ability_err:
        print(e5m1_ability_err, file=sys.stderr)
        return 1

    npc_behavior_errors, npc_behavior_seen_ids, npc_behavior_id_to_row = _validate_npc_behavior_catalogs(
        npc_behavior_files=npc_behavior_files,
        npc_behavior_validator=npc_behavior_validator,
    )
    if npc_behavior_errors:
        print(f"content validation failed with {npc_behavior_errors} error(s)", file=sys.stderr)
        return 1

    e5m2_npc_behavior_err = _prototype_e5m2_npc_behavior_gate(npc_behavior_seen_ids)
    if e5m2_npc_behavior_err:
        print(e5m2_npc_behavior_err, file=sys.stderr)
        return 1

    e5m2_npc_behavior_numeric_err = _prototype_e5m2_npc_behavior_numeric_gate(npc_behavior_id_to_row)
    if e5m2_npc_behavior_numeric_err:
        print(e5m2_npc_behavior_numeric_err, file=sys.stderr)
        return 1

    print(
        "content OK: "
        f"{len(skill_files)} skill catalog file(s), "
        f"{len(curve_files)} level curve file(s), "
        f"{len(mastery_files)} mastery catalog file(s), "
        f"{len(item_files)} item catalog file(s), "
        f"{len(node_files)} resource node catalog file(s), "
        f"{len(yield_files)} resource yield catalog file(s), "
        f"{len(recipe_files)} recipe catalog file(s), "
        f"{len(ability_files)} ability catalog file(s), "
        f"{len(npc_behavior_files)} npc behavior catalog file(s), "
        f"{len(seen_ids)} unique skill id(s), "
        f"{len(item_seen_ids)} unique item id(s), "
        f"{len(node_seen_ids)} unique nodeDefId(s), "
        f"{len(recipe_seen_ids)} unique recipe id(s), "
        f"{len(ability_seen_ids)} unique ability id(s), "
        f"{len(npc_behavior_seen_ids)} unique npc behavior id(s), "
        f"{len(track_skill_ids)} mastery track(s)"
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
