"""Re-import and validate every generated creature animation GLB."""

from __future__ import annotations

import json
from pathlib import Path
import re
import struct

import bpy
from mathutils import Vector


ROOT = Path(__file__).resolve().parents[2]
MODEL_ROOT = ROOT / "game_assets/models/shelved/yugioh"
EXPECTED_CLIPS = {"Idle", "Move", "Attack", "HitReact", "Death"}


def read_glb_json(path: Path) -> dict[str, object]:
    data = path.read_bytes()
    magic, version, total_length = struct.unpack_from("<4sII", data, 0)
    if magic != b"glTF" or version != 2 or total_length != len(data):
        raise RuntimeError(f"{path}: invalid GLB header")
    chunk_length, chunk_type = struct.unpack_from("<II", data, 12)
    if chunk_type != 0x4E4F534A:
        raise RuntimeError(f"{path}: JSON is not the first GLB chunk")
    return json.loads(data[20:20 + chunk_length].decode("utf-8"))


def read_glb_binary(path: Path) -> bytes:
    data = path.read_bytes()
    json_length, json_type = struct.unpack_from("<II", data, 12)
    if json_type != 0x4E4F534A:
        raise RuntimeError(f"{path}: JSON is not the first GLB chunk")
    binary_header = 20 + json_length
    binary_length, binary_type = struct.unpack_from("<II", data, binary_header)
    if binary_type != 0x004E4942:
        raise RuntimeError(f"{path}: binary data is not the second GLB chunk")
    binary_start = binary_header + 8
    return data[binary_start:binary_start + binary_length]


def float_accessor(document: dict[str, object], binary: bytes, index: int) -> list[tuple[float, ...]]:
    accessor = document["accessors"][index]
    if accessor["componentType"] != 5126:
        raise RuntimeError(f"Accessor {index}: expected float data")
    component_counts = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}
    component_count = component_counts[accessor["type"]]
    view = document["bufferViews"][accessor["bufferView"]]
    element_size = component_count * 4
    stride = view.get("byteStride", element_size)
    start = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
    return [
        struct.unpack_from(f"<{component_count}f", binary, start + item * stride)
        for item in range(accessor["count"])
    ]


def values_vary(values: list[tuple[float, ...]], tolerance: float = 1e-5) -> bool:
    first = values[0]
    return any(
        abs(component - first[index]) > tolerance
        for value in values[1:]
        for index, component in enumerate(value)
    )


def validate_pumpking_tentacles(path: Path) -> int:
    document = read_glb_json(path)
    nodes = document.get("nodes", [])
    animations = document.get("animations", [])
    tentacle_nodes = {
        index for index, node in enumerate(nodes)
        if re.fullmatch(r"[LR]_arm\d+_\d+", node.get("name", ""), re.IGNORECASE)
    }
    if not tentacle_nodes:
        raise RuntimeError(f"{path}: no Pumpking tentacle nodes")
    for animation in animations:
        targeted = {channel["target"]["node"] for channel in animation.get("channels", [])}
        missing = tentacle_nodes - targeted
        if missing:
            missing_names = [nodes[index].get("name", str(index)) for index in sorted(missing)]
            raise RuntimeError(f"{path}: {animation.get('name')} misses tentacles {missing_names}")
    return len(tentacle_nodes)


def validate_ultimate_dragon(path: Path) -> dict[str, int]:
    document = read_glb_json(path)
    binary = read_glb_binary(path)
    nodes = document.get("nodes", [])
    animations = document.get("animations", [])
    head_chains = (
        {f"BEUD_LNeck{index}" for index in range(1, 7)} | {"BEUD_LHead"},
        {f"BEUD_Neck{index}" for index in range(1, 7)} | {"BEUD_Head"},
        {f"BEUD_RNeck{index}" for index in range(1, 7)} | {"BEUD_RHead"},
    )
    animated_wings = {
        f"BEUD_{side}Wing{index}"
        for side in ("L", "R")
        for index in range(1, 4)
    }
    static_wings = {
        f"BEUD_{side}Wing{index}"
        for side in ("L", "R")
        for index in range(4, 9)
    }
    head_tips = {"BEUD_LHead", "BEUD_Head", "BEUD_RHead"}
    for animation in animations:
        variation = {}
        rotation_values = {}
        for channel in animation.get("channels", []):
            if channel["target"].get("path") != "rotation":
                continue
            name = nodes[channel["target"]["node"]].get("name", "")
            accessor_index = animation["samplers"][channel["sampler"]]["output"]
            values = float_accessor(document, binary, accessor_index)
            rotation_values[name] = values
            variation[name] = values_vary(values)
        for chain in head_chains:
            static = {name for name in chain if not variation.get(name, False)}
            if static:
                raise RuntimeError(f"{path}: {animation.get('name')} has static head-chain bones {sorted(static)}")
        static_roots = {name for name in animated_wings if not variation.get(name, False)}
        moving_tips = {name for name in static_wings if variation.get(name, False)}
        if static_roots or moving_tips:
            raise RuntimeError(
                f"{path}: {animation.get('name')} static wing roots {sorted(static_roots)}, "
                f"moving wing tips {sorted(moving_tips)}"
            )
        if animation.get("name") in {"Idle", "Move", "Attack"}:
            peak_frames = {}
            for name in head_tips:
                values = rotation_values[name]
                start = values[0]
                distances = [1 - min(1.0, abs(sum(component * start[index] for index, component in enumerate(value)))) for value in values]
                peak_frames[name] = distances.index(max(distances))
            if len(set(peak_frames.values())) != len(head_tips):
                raise RuntimeError(f"{path}: {animation.get('name')} head cadences overlap at {peak_frames}")
    return {
        "headChainBonesPerClip": sum(len(chain) for chain in head_chains),
        "wingBonesPerClip": len(animated_wings),
        "staggeredHeadClips": 3,
    }


def reset_scene() -> None:
    if bpy.context.object and bpy.context.object.mode != "OBJECT":
        bpy.ops.object.mode_set(mode="OBJECT")
    for obj in list(bpy.data.objects):
        bpy.data.objects.remove(obj, do_unlink=True)
    for action in list(bpy.data.actions):
        bpy.data.actions.remove(action)
    for image in list(bpy.data.images):
        bpy.data.images.remove(image)


def validate_red_eyes_motion(rig: bpy.types.Object) -> dict[str, float]:
    """Regression-check the paired flap and bite silhouette in the shipped GLB."""
    rig.animation_data.action = bpy.data.actions["Attack"]

    def sample(frame: int) -> dict[str, Vector]:
        bpy.context.scene.frame_set(frame)
        bpy.context.view_layer.update()
        return {
            "wing_l": rig.pose.bones["bone0079"].tail.copy(),
            "wing_r": rig.pose.bones["bone0075"].tail.copy(),
            "head": rig.pose.bones["bone0073"].tail.copy(),
        }

    windup = sample(8)
    downstroke = sample(14)
    impact = sample(17)
    left_flap = windup["wing_l"].z - downstroke["wing_l"].z
    right_flap = windup["wing_r"].z - downstroke["wing_r"].z
    head_drop = windup["head"].z - impact["head"].z
    head_lunge = windup["head"].y - impact["head"].y
    if left_flap < 0.2 or right_flap < 0.2:
        raise RuntimeError(f"Red-Eyes wings do not complete a paired flap: {left_flap=:.4f}, {right_flap=:.4f}")
    if head_drop < 0.01 or head_lunge < 0.05:
        raise RuntimeError(f"Red-Eyes bite lacks head lift/lunge: {head_drop=:.4f}, {head_lunge=:.4f}")
    return {
        "leftWingDrop": round(left_flap, 4),
        "rightWingDrop": round(right_flap, 4),
        "headDrop": round(head_drop, 4),
        "headLunge": round(head_lunge, 4),
    }


def validate(path: Path) -> dict[str, object]:
    reset_scene()
    bpy.context.scene.render.fps = 30
    bpy.ops.import_scene.gltf(filepath=str(path))
    armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"]
    meshes = [
        obj for obj in bpy.context.scene.objects
        if obj.type == "MESH" and any(
            modifier.type == "ARMATURE" and modifier.object in armatures for modifier in obj.modifiers
        )
    ]
    clips = {action.name: tuple(round(value, 3) for value in action.frame_range) for action in bpy.data.actions}
    if len(armatures) != 1:
        raise RuntimeError(f"{path}: expected one armature, found {len(armatures)}")
    if not meshes:
        raise RuntimeError(f"{path}: no render meshes")
    if set(clips) != EXPECTED_CLIPS:
        raise RuntimeError(f"{path}: clips {sorted(clips)} != {sorted(EXPECTED_CLIPS)}")
    if any(end <= start for start, end in clips.values()):
        raise RuntimeError(f"{path}: empty animation range in {clips}")
    if not bpy.data.images:
        raise RuntimeError(f"{path}: no packed texture images")
    corners = [mesh.matrix_world @ Vector(corner) for mesh in meshes for corner in mesh.bound_box]
    dimensions = [round(max(point[index] for point in corners) - min(point[index] for point in corners), 4) for index in range(3)]
    report = {
        "asset": path.parent.name,
        "sizeBytes": path.stat().st_size,
        "bones": len(armatures[0].data.bones),
        "meshes": len(meshes),
        "images": len(bpy.data.images),
        "dimensions": dimensions,
        "clips": clips,
    }
    if path.parent.name == "pumpking-the-king-of-ghosts":
        report["tentacleBonesPerClip"] = validate_pumpking_tentacles(path)
    if path.parent.name == "blue-eyes-ultimate-dragon":
        report.update(validate_ultimate_dragon(path))
    if path.parent.name == "red-eyes-black-dragon":
        report["motion"] = validate_red_eyes_motion(armatures[0])
    return report


def main() -> None:
    paths = sorted(MODEL_ROOT.glob("*/*-animated.glb"))
    if len(paths) != 7:
        raise RuntimeError(f"Expected 7 generated GLBs, found {len(paths)}")
    report = [validate(path) for path in paths]
    print("YUGIOH_ANIMATION_VALIDATION=" + json.dumps(report, indent=2))


if __name__ == "__main__":
    main()
