#!/usr/bin/env python3
"""Validate the character-roster extension contract without API calls."""
from __future__ import annotations

import argparse
import json
import re
import struct
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parents[1]
SLUG = re.compile(r"^[a-z][a-z0-9_]*$")
JSON_CHUNK = 0x4E4F534A


def read_json(path: Path) -> Any:
    return json.loads(path.read_text())


def require(condition: bool, message: str, failures: list[str]) -> None:
    if not condition:
        failures.append(message)


def read_glb(path: Path) -> dict[str, Any]:
    with path.open("rb") as handle:
        header = handle.read(12)
        if len(header) != 12:
            raise RuntimeError(f"short GLB header: {path}")
        magic, version, length = struct.unpack("<4sII", header)
        if magic != b"glTF" or version != 2 or length != path.stat().st_size:
            raise RuntimeError(f"invalid GLB header: {path}")
        while handle.tell() < length:
            chunk_length, chunk_type = struct.unpack("<II", handle.read(8))
            data = handle.read(chunk_length)
            if chunk_type == JSON_CHUNK:
                return json.loads(data.rstrip(b" \t\r\n\x00"))
    raise RuntimeError(f"missing JSON chunk: {path}")


def animation_ok(path: Path, expected: str) -> bool:
    document = read_glb(path)
    names = [str(row.get("name", "")) for row in document.get("animations", [])]
    return expected in names and bool(document.get("skins"))


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--slug", help="Validate one enabled slug in addition to the whole config")
    parser.add_argument("--all-stages", action="store_true", help="Require generated, rigged, animated, and viewer artifacts")
    args = parser.parse_args()

    failures: list[str] = []
    config_path = ROOT / "character_roster.json"
    require(config_path.is_file(), "missing character_roster.json", failures)
    if failures:
        print(json.dumps({"status": "FAIL", "failures": failures}, indent=2))
        return 2
    config = read_json(config_path)
    require(config.get("schema_version") == 1, "schema_version must be 1", failures)
    require(isinstance(config.get("generation_version"), str) and config["generation_version"], "generation_version is required", failures)
    require(isinstance(config.get("production_suffix"), str) and len(config["production_suffix"]) >= 80, "production_suffix is missing or too short", failures)
    params = config.get("params") or {}
    require(isinstance(params.get("quad"), bool), "params.quad must be a boolean", failures)
    require(isinstance(params.get("face_limit"), int) and params["face_limit"] > 0, "params.face_limit must be a positive integer", failures)
    require(isinstance(params.get("smart_low_poly"), bool), "params.smart_low_poly must be a boolean", failures)

    rows = config.get("characters") or []
    enabled = [row for row in rows if row.get("enabled", True)]
    slugs = [str(row.get("slug", "")) for row in enabled]
    require(bool(enabled), "at least one character must be enabled", failures)
    require(len(slugs) == len(set(slugs)), "enabled character slugs must be unique", failures)
    for index, row in enumerate(enabled):
        prefix = f"characters[{index}]"
        slug = str(row.get("slug", ""))
        require(bool(SLUG.fullmatch(slug)), f"{prefix}.slug must be snake_case", failures)
        for key in ("name", "role", "prompt"):
            require(isinstance(row.get(key), str) and bool(row[key].strip()), f"{prefix}.{key} is required", failures)
        require(len(str(row.get("prompt", ""))) >= 40, f"{prefix}.prompt is too vague", failures)

    if args.slug:
        require(args.slug in slugs, f"slug is not enabled in character_roster.json: {args.slug}", failures)

    if args.all_stages:
        generation_path = ROOT / "generation_manifest.json"
        rigging_path = ROOT / "rigging_manifest.json"
        viewer_manifest_path = ROOT / "viewer_build_manifest.json"
        for path in (generation_path, rigging_path, viewer_manifest_path):
            require(path.is_file(), f"missing {path.name}", failures)
        if not failures:
            generation = read_json(generation_path).get("characters", {})
            rigging = read_json(rigging_path).get("characters", {})
            viewer_manifest = read_json(viewer_manifest_path)
            hosted = {(row["slug"], row["motion"]): row for row in viewer_manifest.get("files", [])}
            targets = [args.slug] if args.slug else slugs
            for slug in targets:
                generated = generation.get(slug, {})
                require(generated.get("status") == "downloaded", f"{slug}: source generation is not downloaded", failures)
                source = ROOT / "sources" / f"{slug}.glb"
                require(source.is_file() and source.stat().st_size > 1024, f"{slug}: missing source GLB", failures)
                if source.is_file():
                    try:
                        read_glb(source)
                    except Exception as error:
                        failures.append(f"{slug}: source GLB invalid: {error}")
                record = rigging.get(slug, {})
                require(record.get("rig_status") == "success", f"{slug}: rig is not successful", failures)
                for motion, expected in (("idle", "preset:biped:idle"), ("boast", "preset:biped:victory_celebration")):
                    clip = record.get("clips", {}).get(motion, {})
                    require(clip.get("status") == "success", f"{slug}: {motion} clip is not successful", failures)
                    output = clip.get("output", {}).get("file")
                    path = ROOT / output if output else None
                    require(bool(path and path.is_file()), f"{slug}: missing {motion} GLB", failures)
                    if path and path.is_file():
                        try:
                            require(animation_ok(path, expected), f"{slug}: {motion} GLB lacks expected skin/animation", failures)
                        except Exception as error:
                            failures.append(f"{slug}: invalid {motion} GLB: {error}")
                    require((slug, motion) in hosted, f"{slug}: {motion} is absent from viewer_build_manifest.json", failures)
            require(viewer_manifest.get("characters") == len(slugs), "viewer character count does not match enabled config", failures)
            guide_names = {row.get("path") for row in viewer_manifest.get("guides", [])}
            require("guides/ADDING_CHARACTERS.md" in guide_names, "viewer manifest does not publish ADDING_CHARACTERS.md", failures)

    result = {
        "status": "PASS" if not failures else "FAIL",
        "enabled_characters": len(enabled),
        "checked_slug": args.slug,
        "all_stages": args.all_stages,
        "failures": failures,
    }
    print(json.dumps(result, indent=2))
    return 0 if not failures else 2


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