#!/usr/bin/env python3
"""GUTS manifest validator (Lane D) — the qa.sh hook. Exit 0 = green, 1 = errors.

Enforces the house laws that a manifest can violate (TECH.md):
  * every referenced url actually exists on disk (a manifest that lies is worse than none)
  * WebP dimensions <= 1024, matcaps square
  * every wall texture carries a sane `tile` hint (Lane A reads it)
  * audio ships OGG **and** M4A (Safari has no Opus-in-ogg) and stays under the 10 MB budget
  * no orphan files in assets/gen (harvested but uncatalogued => invisible to the game)

Stdlib ONLY — no numpy, no PIL. qa.sh runs on any box, including ones with a bare python3,
so the WebP header is parsed by hand below.

    python3 pipeline/validate_manifest.py [--quiet]
"""
import json, os, struct, sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ASSETS = os.path.join(ROOT, "web", "assets")
MANIFEST = os.path.join(ASSETS, "manifest.json")
AUDIO_BUDGET_MB = 10.0
MAX_DIM = 1024

errs, warns = [], []


def webp_size(path):
    """(w, h) from a WebP header — VP8 (lossy) / VP8L (lossless) / VP8X (extended).
    Returns None if the fourcc is unrecognised rather than guessing."""
    with open(path, "rb") as f:
        b = f.read(32)
    if len(b) < 30 or b[:4] != b"RIFF" or b[8:12] != b"WEBP":
        return None
    fourcc = b[12:16]
    if fourcc == b"VP8 ":
        # frame tag (3B) + start code 9d 01 2a (3B) then 16-bit w,h with 2 scale bits on top
        w, h = struct.unpack("<HH", b[26:30])
        return w & 0x3FFF, h & 0x3FFF
    if fourcc == b"VP8L":
        n = struct.unpack("<I", b[21:25])[0]
        return (n & 0x3FFF) + 1, ((n >> 14) & 0x3FFF) + 1
    if fourcc == b"VP8X":
        w = b[24] | (b[25] << 8) | (b[26] << 16)
        h = b[27] | (b[28] << 8) | (b[29] << 16)
        return w + 1, h + 1
    return None


def check_file(rel, label):
    p = os.path.join(ASSETS, rel)
    if not os.path.exists(p):
        errs.append(f"{label}: missing file {rel}")
        return None
    return p


def main():
    quiet = "--quiet" in sys.argv
    if not os.path.exists(MANIFEST):
        # An absent manifest is LEGAL: the game must boot asset-free. Say so and pass.
        print("validate_manifest: no manifest.json — asset-free boot (legal, TECH.md §assets)")
        return 0
    try:
        m = json.load(open(MANIFEST))
    except Exception as e:
        print(f"validate_manifest: manifest.json does not parse: {e}")
        return 1

    seen = set()
    for name, e in (m.get("textures") or {}).items():
        p = check_file(e.get("url", ""), f"texture {name}")
        if p:
            seen.add(os.path.basename(p))
            wh = webp_size(p)
            if wh and max(wh) > MAX_DIM:
                errs.append(f"texture {name}: {wh[0]}x{wh[1]} exceeds {MAX_DIM} (house law)")
        if "normal" in e:
            np_ = check_file(e["normal"], f"texture {name} normal")
            if np_:
                seen.add(os.path.basename(np_))
        t = e.get("tile")
        if not (isinstance(t, list) and len(t) == 2 and all(isinstance(v, (int, float)) and v > 0 for v in t)):
            errs.append(f"texture {name}: bad tile {t!r} — want [repeats_theta, units_per_s]")

    for name, e in (m.get("matcaps") or {}).items():
        p = check_file(e.get("url", ""), f"matcap {name}")
        if p:
            seen.add(os.path.basename(p))
            wh = webp_size(p)
            if wh and wh[0] != wh[1]:
                errs.append(f"matcap {name}: {wh[0]}x{wh[1]} is not square")
            if wh and max(wh) > MAX_DIM:
                errs.append(f"matcap {name}: {wh[0]}x{wh[1]} exceeds {MAX_DIM}")

    for name, e in (m.get("models") or {}).items():
        check_file(e.get("url", ""), f"model {name}")
        tris = e.get("tris")
        if tris and tris > 5000:
            errs.append(f"model {name}: {tris} tris exceeds house law 5000 (decimate)")
        if not tris:
            warns.append(f"model {name}: no tris count recorded (run glb_stat)")

    audio_bytes = 0
    for cat in ("beds", "sfx"):
        for name, e in ((m.get("audio") or {}).get(cat) or {}).items():
            for fmt in ("ogg", "m4a"):
                if fmt not in e:
                    errs.append(f"audio {cat}/{name}: no {fmt} (dual-ship law: Safari needs m4a)")
                    continue
                p = check_file(e[fmt], f"audio {cat}/{name}")
                if p:
                    audio_bytes += os.path.getsize(p)
    if audio_bytes / 1e6 > AUDIO_BUDGET_MB:
        errs.append(f"audio total {audio_bytes/1e6:.1f}MB exceeds {AUDIO_BUDGET_MB}MB budget")

    gendir = os.path.join(ASSETS, "gen")
    if os.path.isdir(gendir):
        for f in sorted(os.listdir(gendir)):
            if f.endswith(".webp") and f not in seen:
                warns.append(f"orphan {f} in gen/ — harvested but not in manifest "
                             f"(re-run build_manifest.py)")

    if not quiet:
        n = m.get("textures", {}), m.get("matcaps", {}), (m.get("audio") or {})
        print(f"validate_manifest: {len(n[0])} textures, {len(n[1])} matcaps, "
              f"{len(n[2].get('beds', {}))} beds, {len(n[2].get('sfx', {}))} sfx, "
              f"audio {audio_bytes/1e6:.2f}/{AUDIO_BUDGET_MB}MB")
    for w in warns:
        print(f"  warn: {w}")
    for e in errs:
        print(f"  ERROR: {e}")
    return 1 if errs else 0


if __name__ == "__main__":
    sys.exit(main())
