"""Build animation-ready GLBs for the locally-authored inspired creature set.

The legacy source FBX files use ASCII encoding, which modern Blender cannot read.
Assimp converts each DAE to glTF without changing the source assets; Blender then
adds reusable actions and exports a texture-packed GLB beside each source model.
"""

from __future__ import annotations

from dataclasses import dataclass
import json
import math
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile

import bpy
from mathutils import Vector


ROOT = Path(__file__).resolve().parents[2]
MODEL_ROOT = ROOT / "game_assets/models/shelved/yugioh"
FPS = 30
CLIP_SPECS = {
    "Idle": {"frames": 60, "loop": True},
    "Move": {"frames": 36, "loop": True},
    "Attack": {"frames": 36, "loop": False},
    "HitReact": {"frames": 24, "loop": False},
    "Death": {"frames": 72, "loop": False},
}


@dataclass(frozen=True)
class AssetProfile:
    asset_id: str
    display_name: str
    body: tuple[str, ...]
    creature: str
    red_eyes_roles: bool = False


PROFILES = (
    AssetProfile("blue-eyes-ultimate-dragon", "Tri-Headed Ivory Dragon", ("BEUD_UpperBody", "BEUD_Lumbar1"), "dragon"),
    AssetProfile("blue-eyes-white-dragon", "Ivory-Eyed Sky Dragon", ("MMD_004007_UpperBody", "MMD_004007_Lumbar1"), "dragon"),
    AssetProfile("gandora-the-dragon-of-destruction", "Ruin-Orb Black Dragon", ("UpperBody", "Lumber1"), "dragon"),
    AssetProfile("gate-guardian", "Tri-Element Gate Sentinel", ("Spine", "Spine1", "Spine2"), "guardian"),
    AssetProfile("insect-queen", "Royal Carapace Matriarch", ("UpperBody", "Spine1"), "insect"),
    AssetProfile("pumpking-the-king-of-ghosts", "Crowned Vine Wraith", ("hips", "upper_01", "upper_02"), "wraith"),
    AssetProfile("red-eyes-black-dragon", "Crimson-Eyed Night Dragon", ("bone0000", "bone0076", "bone0009"), "dragon", True),
)


RED_EYES = {
    "body": ("bone0000", "bone0076", "bone0009", "bone0078", "bone0057"),
    "head": ("bone0025", "bone0031", "bone0047", "bone0073"),
    "jaw": ("bone0001", "bone0002", "bone0029", "bone0010", "bone0020"),
    "wing_l": ("bone0016", "bone0006", "bone0049", "bone0032", "bone0003", "bone0058", "bone0008", "bone0063", "bone0079", "bone0069", "bone0013", "bone0030", "bone0077"),
    "wing_r": ("bone0033", "bone0012", "bone0055", "bone0046", "bone0021", "bone0045", "bone0041", "bone0060", "bone0075", "bone0070", "bone0042", "bone0024", "bone0040"),
    "arm_l": ("bone0048", "bone0061", "bone0005", "bone0015", "bone0038", "bone0017", "bone0028", "bone0023"),
    "arm_r": ("bone0059", "bone0027", "bone0071", "bone0022", "bone0052", "bone0064", "bone0056", "bone0074", "bone0067"),
    "leg_l": ("bone0004", "bone0039", "bone0051", "bone0014", "bone0035", "bone0050", "bone0066"),
    "leg_r": ("bone0007", "bone0043", "bone0011", "bone0036", "bone0026", "bone0037", "bone0062"),
    "tail": ("bone0034", "bone0053", "bone0019", "bone0044", "bone0054", "bone0065", "bone0072", "bone0018"),
}


ULTIMATE_HEAD_CHAINS = {
    "head_l": tuple(f"BEUD_LNeck{index}" for index in range(1, 7)) + ("BEUD_LHead",),
    "head_c": tuple(f"BEUD_Neck{index}" for index in range(1, 7)) + ("BEUD_Head",),
    "head_r": tuple(f"BEUD_RNeck{index}" for index in range(1, 7)) + ("BEUD_RHead",),
}


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 blocks in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials, bpy.data.images, bpy.data.actions, bpy.data.cameras, bpy.data.lights):
        for block in list(blocks):
            blocks.remove(block)


def parse_smd_bones(path: Path, count: int) -> list[str]:
    nodes: list[tuple[int, str, int]] = []
    in_nodes = False
    for line in path.read_text(errors="replace").splitlines():
        if line == "nodes":
            in_nodes = True
            continue
        if in_nodes and line == "end":
            break
        if not in_nodes:
            continue
        match = re.fullmatch(r'(\d+)\s+"(.+)"\s+(-?\d+)', line)
        if match:
            nodes.append((int(match.group(1)), match.group(2), int(match.group(3))))
    if len(nodes) < count:
        raise RuntimeError(f"{path}: found {len(nodes)} nodes for {count} Blender bones")
    return [name for _, name, _ in nodes[-count:]]


def convert_and_import(profile: AssetProfile) -> tuple[bpy.types.Object, list[bpy.types.Object], Path]:
    source_dir = MODEL_ROOT / profile.asset_id
    dae_path = next(source_dir.glob("MMD_*.dae"))
    smd_path = dae_path.with_suffix(".smd")
    with tempfile.TemporaryDirectory(prefix=f"thor-{profile.asset_id}-") as temp_dir_name:
        temp_dir = Path(temp_dir_name)
        gltf_path = temp_dir / "model.gltf"
        subprocess.run(
            ["assimp", "export", str(dae_path), str(gltf_path), "-f", "gltf2"],
            check=True,
            capture_output=True,
            text=True,
        )
        for image_path in source_dir.glob("*.png"):
            shutil.copy2(image_path, temp_dir / image_path.name)
        bpy.ops.import_scene.gltf(filepath=str(gltf_path), import_pack_images=True)

    armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"]
    if len(armatures) != 1:
        raise RuntimeError(f"{profile.asset_id}: expected one armature")
    rig = armatures[0]
    meshes = [
        obj for obj in bpy.context.scene.objects
        if obj.type == "MESH" and (
            obj.parent == rig
            or any(modifier.type == "ARMATURE" and modifier.object == rig for modifier in obj.modifiers)
        )
    ]
    if not meshes:
        raise RuntimeError(f"{profile.asset_id}: expected one armature and at least one mesh")
    for helper in [obj for obj in bpy.context.scene.objects if obj.type == "MESH" and obj not in meshes]:
        bpy.data.objects.remove(helper, do_unlink=True)
    source_names = parse_smd_bones(smd_path, len(rig.data.bones))
    old_names = [bone.name for bone in rig.data.bones]
    for old_name, source_name in zip(old_names, source_names, strict=True):
        rig.data.bones[old_name].name = source_name
        for mesh in meshes:
            group = mesh.vertex_groups.get(old_name)
            if group:
                group.name = source_name

    rig.name = f"{profile.asset_id}_Rig"
    rig.data.name = f"{profile.asset_id}_Armature"
    for index, mesh in enumerate(meshes):
        mesh.name = f"{profile.asset_id}_Mesh_{index:02d}"
        mesh.data.name = f"{profile.asset_id}_Geometry_{index:02d}"
    for action in list(bpy.data.actions):
        bpy.data.actions.remove(action)
    rig.animation_data_clear()
    return rig, meshes, dae_path


def side(name: str) -> str | None:
    lower = name.lower()
    if "left" in lower or re.search(r"(^|[_\.])l(?:wing|arm|hand|foot|thigh|shin|shoulder|bicep|forearm|sensor)", lower):
        return "l"
    if "right" in lower or re.search(r"(^|[_\.])r(?:wing|arm|hand|foot|thigh|shin|shoulder|bicep|forearm|sensor)", lower):
        return "r"
    if lower.startswith("l") and any(token in lower for token in ("wing", "arm", "hand", "foot", "thigh", "shin", "sensor")):
        return "l"
    if lower.startswith("r") and any(token in lower for token in ("wing", "arm", "hand", "foot", "thigh", "shin", "sensor")):
        return "r"
    return None


def build_roles(profile: AssetProfile, rig: bpy.types.Object) -> dict[str, list[str]]:
    names = [bone.name for bone in rig.data.bones]
    if profile.red_eyes_roles:
        return {role: [name for name in role_names if name in names] for role, role_names in RED_EYES.items()}
    roles = {key: [] for key in ("body", "head", "jaw", "wing_l", "wing_r", "arm_l", "arm_r", "leg_l", "leg_r", "tail")}
    roles["body"] = [name for name in profile.body if name in names]
    for name in names:
        lower = name.lower()
        bone_side = side(name)
        if "wing" in lower:
            if bone_side:
                roles[f"wing_{bone_side}"].append(name)
        elif any(token in lower for token in ("arm", "hand", "shoulder", "bicep", "forearm")):
            if bone_side:
                roles[f"arm_{bone_side}"].append(name)
        elif any(token in lower for token in ("foot", "thigh", "shin", "upleg")) or (profile.creature == "insect" and "foot" in lower):
            if bone_side:
                roles[f"leg_{bone_side}"].append(name)
        if "tail" in lower:
            roles["tail"].append(name)
        if "head" in lower or (profile.creature == "insect" and lower in {"neck1", "neck2"}):
            roles["head"].append(name)
        if any(token in lower for token in ("jaw", "chin", "mandible", "mouth")):
            roles["jaw"].append(name)
    if profile.creature == "wraith":
        roles["arm_l"] = [name for name in names if name.lower().startswith("l_arm")]
        roles["arm_r"] = [name for name in names if name.lower().startswith("r_arm")]
        roles["head"].extend(name for name in ("eye", "crown", "eye_upper", "eye_lower") if name in names)
        roles["jaw"].extend(name for name in ("chin_upper", "chin_lower", "under") if name in names)
    if profile.creature == "insect":
        roles["head"].extend(name for name in names if "sensor" in name.lower())
    if profile.asset_id == "blue-eyes-ultimate-dragon":
        for role, chain in ULTIMATE_HEAD_CHAINS.items():
            roles[role] = [name for name in chain if name in names]
    return {role: list(dict.fromkeys(role_names)) for role, role_names in roles.items()}


def rotate_role(roles: dict[str, list[str]], role: str, degrees: tuple[float, float, float], limit: int = 99, falloff: float = 0.72) -> dict[str, tuple[float, float, float]]:
    result = {}
    for index, name in enumerate(roles.get(role, [])[:limit]):
        factor = falloff ** index
        result[name] = tuple(component * factor for component in degrees)
    return result


def merge(*maps: dict[str, tuple[float, float, float]]) -> dict[str, tuple[float, float, float]]:
    merged: dict[str, tuple[float, float, float]] = {}
    for values in maps:
        for name, rotation in values.items():
            previous = merged.get(name, (0.0, 0.0, 0.0))
            merged[name] = tuple(previous[index] + rotation[index] for index in range(3))
    return merged


def dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
    neutral: dict[str, tuple[float, float, float]] = {}
    wings_up = merge(rotate_role(roles, "wing_l", (0, -14, 18), 5), rotate_role(roles, "wing_r", (0, 14, -18), 5))
    wings_down = merge(rotate_role(roles, "wing_l", (0, 18, -14), 5), rotate_role(roles, "wing_r", (0, -18, 14), 5))
    tail_l = rotate_role(roles, "tail", (0, 0, 8), 8, 0.82)
    tail_r = rotate_role(roles, "tail", (0, 0, -8), 8, 0.82)
    legs_a = merge(rotate_role(roles, "leg_l", (10, 0, 0), 2), rotate_role(roles, "leg_r", (-10, 0, 0), 2))
    legs_b = merge(rotate_role(roles, "leg_l", (-10, 0, 0), 2), rotate_role(roles, "leg_r", (10, 0, 0), 2))
    return {
        "Idle": [(1, neutral), (16, merge(wings_up, tail_l, rotate_role(roles, "head", (2, 0, 0), 3))), (31, neutral), (46, merge(wings_down, tail_r, rotate_role(roles, "head", (-2, 0, 0), 3))), (60, neutral)],
        "Move": [(1, merge(wings_up, legs_a, tail_l)), (10, neutral), (19, merge(wings_down, legs_b, tail_r)), (28, neutral), (36, merge(wings_up, legs_a, tail_l))],
        "Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-10, 0, 0), 2), wings_up, rotate_role(roles, "head", (-14, 0, 0), 4), rotate_role(roles, "jaw", (-8, 0, 0), 3))), (16, merge(rotate_role(roles, "body", (17, 0, 0), 2), wings_down, rotate_role(roles, "head", (24, 0, 0), 5), rotate_role(roles, "jaw", (24, 0, 0), 4), rotate_role(roles, "arm_l", (18, 0, -14), 3), rotate_role(roles, "arm_r", (18, 0, 14), 3))), (25, merge(rotate_role(roles, "head", (5, 0, 0), 4), rotate_role(roles, "jaw", (8, 0, 0), 3))), (36, neutral)],
        "HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 12), 3), rotate_role(roles, "head", (-22, 0, -10), 5), wings_up)), (13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)), (24, neutral)],
        "Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-20, 0, 18), 3), rotate_role(roles, "head", (18, 0, 8), 5), wings_down)), (44, merge(rotate_role(roles, "body", (10, 62, 70), 3), rotate_role(roles, "head", (28, 0, 12), 5), rotate_role(roles, "wing_l", (0, 34, -28), 5), rotate_role(roles, "wing_r", (0, -34, 28), 5), rotate_role(roles, "leg_l", (28, 0, 0), 3), rotate_role(roles, "leg_r", (-18, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (10, 62, 70), 3), rotate_role(roles, "head", (30, 0, 12), 5), rotate_role(roles, "wing_l", (0, 34, -28), 5), rotate_role(roles, "wing_r", (0, -34, 28), 5)))],
    }


def ultimate_head_pose(
    roles: dict[str, list[str]],
    left: tuple[float, float, float],
    center: tuple[float, float, float],
    right: tuple[float, float, float],
) -> dict[str, tuple[float, float, float]]:
    """Bend each long neck as a chain; rotating only head bones reads as static."""
    result: dict[str, tuple[float, float, float]] = {}
    weights = (0.10, 0.14, 0.17, 0.18, 0.16, 0.12, 0.22)
    for role, motion in zip(("head_l", "head_c", "head_r"), (left, center, right), strict=True):
        pitch, yaw, roll = motion
        for index, name in enumerate(roles.get(role, [])):
            weight = weights[min(index, len(weights) - 1)]
            result[name] = (roll * weight, pitch * weight, yaw * weight)
    return result


def ultimate_living_heads(
    roles: dict[str, list[str]],
    progress: float,
    intensity: float = 1.0,
) -> dict[str, tuple[float, float, float]]:
    """Use distinct harmonic timing so three heads never bob in lockstep."""
    phase = math.tau * progress
    left = (
        intensity * (2.8 * math.sin(phase) + 0.8 * math.sin(phase * 2 + 0.35)),
        intensity * (2.0 * math.sin(phase * 2 + 0.75)),
        intensity * 0.8 * math.sin(phase + 0.2),
    )
    center = (
        intensity * (2.5 * math.sin(phase + 2.05) + 0.65 * math.sin(phase * 3 + 0.25)),
        intensity * (1.5 * math.sin(phase * 3 + 1.4)),
        intensity * 0.65 * math.sin(phase + 2.6),
    )
    right = (
        intensity * (2.7 * math.sin(phase + 4.15) + 0.75 * math.sin(phase * 2 + 1.15)),
        intensity * (1.9 * math.sin(phase * 2 + 3.7)),
        intensity * 0.75 * math.sin(phase + 4.55),
    )
    return ultimate_head_pose(roles, left, center, right)


def ultimate_wings(flare: float) -> dict[str, tuple[float, float, float]]:
    """Flap around local Z only; local Y sweeps these wings around body."""
    result: dict[str, tuple[float, float, float]] = {}
    for prefix, sign in (("BEUD_LWing", 1), ("BEUD_RWing", -1)):
        for index, weight in ((1, 1.0), (2, 0.34), (3, 0.12)):
            result[f"{prefix}{index}"] = (0.0, 0.0, sign * flare * weight)
    return result


def ultimate_jaws(left: float, center: float, right: float) -> dict[str, tuple[float, float, float]]:
    """Jaw hinges use local Y; local X would twist along snout."""
    return {
        "BEUD_LChin": (0.0, left, 0.0),
        "BEUD_Chin": (0.0, center, 0.0),
        "BEUD_RChin": (0.0, right, 0.0),
    }


def ultimate_dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
    idle = []
    for frame in (1, 6, 11, 16, 21, 26, 31, 36, 41, 46, 51, 56, 60):
        progress = (frame - 1) / 59
        idle.append((frame, merge(
            ultimate_living_heads(roles, progress),
            ultimate_wings(1.8 * math.sin(math.tau * progress)),
            rotate_role(roles, "tail", (0, 0, 4.0 * math.sin(math.tau * progress + 0.6)), 8, 0.82),
        )))

    move = []
    for frame in (1, 5, 9, 13, 17, 21, 25, 29, 33, 36):
        progress = (frame - 1) / 35
        stride = math.sin(math.tau * progress)
        move.append((frame, merge(
            ultimate_living_heads(roles, progress, 1.2),
            ultimate_wings(7.0 * stride),
            rotate_role(roles, "tail", (0, 0, 5.5 * math.sin(math.tau * progress + 0.8)), 8, 0.82),
            rotate_role(roles, "leg_l", (5.0 * stride, 0, 0), 2),
            rotate_role(roles, "leg_r", (-5.0 * stride, 0, 0), 2),
        )))

    attack_specs = (
        (1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
        (6, (-4, 2, -1), (-2, -1, 0), (-1, -2, 1), (0, 0, 0), 2),
        (10, (-8, 3, -1), (-5, 0, 0), (-3, -3, 1), (4, 0, 0), 4),
        (14, (11, -2, 1), (-8, 1, 0), (-5, -1, 1), (24, 3, 0), -3),
        (17, (6, 1, 0), (12, 0, 0), (-8, 2, -1), (8, 25, 3), -4),
        (21, (3, -1, 0), (7, -1, 0), (13, 2, -1), (2, 9, 25), -2),
        (28, (-1, 1, 0), (2, 0, 0), (5, -1, 0), (0, 2, 8), 1),
        (36, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
    )
    attack = [
        (frame, merge(
            ultimate_head_pose(roles, left, center, right),
            ultimate_jaws(*jaws),
            ultimate_wings(wing),
        ))
        for frame, left, center, right, jaws, wing in attack_specs
    ]

    hit_specs = (
        (1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
        (4, (-10, -3, 2), (-6, 2, -1), (-4, 3, -2), (-14, 0, 10), 4),
        (7, (-5, 2, -1), (-11, -2, 1), (-7, -3, 2), (-8, 0, 6), 2),
        (11, (3, -1, 0), (-4, 1, 0), (-10, 2, -1), (5, 0, -4), -1),
        (16, (-1, 1, 0), (3, -1, 0), (-2, 0, 0), (2, 0, -2), 0),
        (24, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
    )
    hit_react = [
        (frame, merge(
            ultimate_head_pose(roles, left, center, right),
            rotate_role(roles, "body", body, 2),
            ultimate_wings(wing),
        ))
        for frame, left, center, right, body, wing in hit_specs
    ]

    death_specs = (
        (1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
        (14, (-5, -3, 2), (-3, 2, -1), (-7, 2, -2), (-10, 0, 8), 2),
        (25, (-14, -7, 4), (-8, 4, -2), (-18, 6, -5), (-16, 8, 18), -3),
        (42, (-23, -10, 7), (-17, 6, -4), (-28, 9, -8), (8, 48, 58), -7),
        (58, (-30, -13, 9), (-24, 8, -6), (-34, 11, -10), (10, 62, 70), -9),
        (72, (-32, -14, 10), (-27, 9, -7), (-36, 12, -11), (10, 62, 70), -9),
    )
    death = [
        (frame, merge(
            ultimate_head_pose(roles, left, center, right),
            rotate_role(roles, "body", body, 2),
            ultimate_wings(wing),
        ))
        for frame, left, center, right, body, wing in death_specs
    ]

    return {"Idle": idle, "Move": move, "Attack": attack, "HitReact": hit_react, "Death": death}


def red_eyes_dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
    """Author Red-Eyes motion around its unusually oriented local bone axes.

    This rig's wing bones do not share the local axes used by the named dragon
    rigs. Rotating every wing segment as one role makes each membrane chain curl
    forward like an arm. Instead, rotate the two shoulder roots in mirrored
    armature-space directions and add only a small elbow fold. Both sides use
    identical keyframe timing so they read as one aerodynamic flap.
    """
    neutral: dict[str, tuple[float, float, float]] = {}
    wings_up = {
        "bone0016": (38.0, 32.8, 38.3),
        "bone0033": (-38.0, -32.8, 38.3),
        "bone0006": (-11.3, 14.0, -0.4),
        "bone0012": (11.3, -14.0, -0.4),
    }
    wings_down = {
        "bone0016": (-12.8, -28.5, -13.1),
        "bone0033": (12.8, 28.5, -13.1),
        "bone0006": (7.6, -9.3, -1.3),
        "bone0012": (-7.6, 9.3, -1.3),
    }
    wings_recover = {
        "bone0016": (15.5, 16.0, 15.7),
        "bone0033": (-15.5, -16.0, 15.7),
        "bone0006": (-5.0, 6.0, -0.2),
        "bone0012": (5.0, -6.0, -0.2),
    }
    tail_l = rotate_role(roles, "tail", (0, 0, 7), 5, 0.78)
    tail_r = rotate_role(roles, "tail", (0, 0, -7), 5, 0.78)
    legs_a = merge(rotate_role(roles, "leg_l", (8, 0, 0), 2), rotate_role(roles, "leg_r", (-8, 0, 0), 2))
    legs_b = merge(rotate_role(roles, "leg_l", (-8, 0, 0), 2), rotate_role(roles, "leg_r", (8, 0, 0), 2))

    # The neck runs forward/down in armature space. Its local Z axis is the
    # pitch axis: negative lifts the head for anticipation; positive drives the
    # snout down through the bite. Jaw roots open during the windup, then close
    # at impact instead of swinging the forelimbs as the attack silhouette.
    head_lift = {"bone0025": (0, 0, 45), "bone0031": (0, 0, 15)}
    head_bite = {"bone0025": (0, 0, -32), "bone0031": (0, 0, -10)}
    head_follow_through = {"bone0025": (0, 0, -18), "bone0031": (0, 0, -6)}
    jaw_open = {"bone0001": (24, 0, 0), "bone0029": (24, 0, 0), "bone0020": (20, 0, 0)}
    jaw_closed = {"bone0001": (-4, 0, 0), "bone0029": (-4, 0, 0), "bone0020": (-3, 0, 0)}

    return {
        "Idle": [
            (1, neutral),
            (14, wings_up),
            (28, neutral),
            (42, wings_down),
            (54, wings_recover),
            (60, neutral),
        ],
        "Move": [
            (1, merge(wings_up, legs_a, tail_l)),
            (9, neutral),
            (18, merge(wings_down, legs_b, tail_r)),
            (27, neutral),
            (36, merge(wings_up, legs_a, tail_l)),
        ],
        "Attack": [
            (1, neutral),
            (8, merge(wings_up, head_lift, jaw_open)),
            (14, merge(wings_down, head_bite, jaw_open)),
            (17, merge(wings_down, head_bite, jaw_closed)),
            (23, merge(wings_recover, head_follow_through)),
            (30, neutral),
            (36, neutral),
        ],
        "HitReact": [
            (1, neutral),
            (5, merge(rotate_role(roles, "body", (-18, 0, 12), 2), head_lift, wings_up)),
            (13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)),
            (24, neutral),
        ],
        "Death": [
            (1, neutral),
            (18, merge(rotate_role(roles, "body", (-20, 0, 18), 3), head_follow_through, wings_down)),
            (44, merge(rotate_role(roles, "body", (10, 62, 70), 3), head_bite, wings_down, rotate_role(roles, "leg_l", (28, 0, 0), 3), rotate_role(roles, "leg_r", (-18, 0, 0), 3))),
            (72, merge(rotate_role(roles, "body", (10, 62, 70), 3), head_bite, wings_down)),
        ],
    }


def guardian_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
    neutral: dict[str, tuple[float, float, float]] = {}
    arms_open = merge(rotate_role(roles, "arm_l", (0, -8, -12), 3), rotate_role(roles, "arm_r", (0, 8, 12), 3))
    arms_close = merge(rotate_role(roles, "arm_l", (0, 5, 8), 3), rotate_role(roles, "arm_r", (0, -5, -8), 3))
    step_a = merge(rotate_role(roles, "leg_l", (-20, 0, 0), 3), rotate_role(roles, "leg_r", (20, 0, 0), 3), rotate_role(roles, "arm_l", (14, 0, 0), 3), rotate_role(roles, "arm_r", (-14, 0, 0), 3))
    step_b = merge(rotate_role(roles, "leg_l", (20, 0, 0), 3), rotate_role(roles, "leg_r", (-20, 0, 0), 3), rotate_role(roles, "arm_l", (-14, 0, 0), 3), rotate_role(roles, "arm_r", (14, 0, 0), 3))
    return {
        "Idle": [(1, neutral), (16, merge(arms_open, rotate_role(roles, "body", (2, 0, 0), 2))), (31, neutral), (46, merge(arms_close, rotate_role(roles, "body", (-2, 0, 0), 2))), (60, neutral)],
        "Move": [(1, step_a), (10, neutral), (19, step_b), (28, neutral), (36, step_a)],
        "Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (0, -18, -8), 3), rotate_role(roles, "arm_r", (-35, 15, 26), 5))), (15, merge(rotate_role(roles, "body", (8, 20, 8), 3), rotate_role(roles, "arm_r", (42, -12, -35), 5), rotate_role(roles, "arm_l", (12, 0, 8), 3), rotate_role(roles, "jaw", (16, 0, 0), 2))), (25, rotate_role(roles, "arm_r", (8, 0, -8), 4)), (36, neutral)],
        "HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 14), 3), arms_open)), (13, merge(rotate_role(roles, "body", (8, 0, -6), 2), arms_close)), (24, neutral)],
        "Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-24, 0, 18), 3), arms_open)), (44, merge(rotate_role(roles, "body", (12, 65, 78), 3), rotate_role(roles, "arm_l", (24, 0, -38), 5), rotate_role(roles, "arm_r", (-18, 0, 34), 5), rotate_role(roles, "leg_l", (28, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (12, 65, 78), 3), rotate_role(roles, "arm_l", (26, 0, -40), 5), rotate_role(roles, "arm_r", (-20, 0, 36), 5)))],
    }


def insect_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
    neutral: dict[str, tuple[float, float, float]] = {}
    legs_a = merge(rotate_role(roles, "leg_l", (12, 0, -10), 4), rotate_role(roles, "leg_r", (-12, 0, 10), 4))
    legs_b = merge(rotate_role(roles, "leg_l", (-12, 0, 10), 4), rotate_role(roles, "leg_r", (12, 0, -10), 4))
    wings_up = merge(rotate_role(roles, "wing_l", (0, -18, 20), 3), rotate_role(roles, "wing_r", (0, 18, -20), 3))
    wings_down = merge(rotate_role(roles, "wing_l", (0, 22, -16), 3), rotate_role(roles, "wing_r", (0, -22, 16), 3))
    return {
        "Idle": [(1, neutral), (16, merge(wings_up, rotate_role(roles, "head", (0, 0, 5), 4))), (31, neutral), (46, merge(wings_down, rotate_role(roles, "head", (0, 0, -5), 4))), (60, neutral)],
        "Move": [(1, merge(legs_a, wings_up)), (10, neutral), (19, merge(legs_b, wings_down)), (28, neutral), (36, merge(legs_a, wings_up))],
        "Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-10, 0, 0), 2), rotate_role(roles, "arm_l", (-20, 0, -20), 3), rotate_role(roles, "arm_r", (-20, 0, 20), 3), wings_up)), (15, merge(rotate_role(roles, "body", (18, 0, 0), 2), rotate_role(roles, "head", (22, 0, 0), 4), rotate_role(roles, "jaw", (25, 0, 0), 4), rotate_role(roles, "arm_l", (28, 0, 22), 3), rotate_role(roles, "arm_r", (28, 0, -22), 3))), (25, rotate_role(roles, "jaw", (8, 0, 0), 3)), (36, neutral)],
        "HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 12), 3), wings_up, legs_b)), (13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)), (24, neutral)],
        "Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-18, 0, 16), 3), wings_down)), (44, merge(rotate_role(roles, "body", (8, 58, 76), 3), rotate_role(roles, "leg_l", (30, 0, 22), 5), rotate_role(roles, "leg_r", (-24, 0, -18), 5), rotate_role(roles, "wing_l", (0, 32, -28), 3), rotate_role(roles, "wing_r", (0, -32, 28), 3))), (72, merge(rotate_role(roles, "body", (8, 58, 76), 3), rotate_role(roles, "leg_l", (30, 0, 22), 5), rotate_role(roles, "leg_r", (-24, 0, -18), 5)))],
    }


def wraith_tentacles(
    roles: dict[str, list[str]],
    sweep: float,
    curl: float,
    lift: float = 0,
    phase: float = 0,
) -> dict[str, tuple[float, float, float]]:
    """Pose every independently rigged vine chain instead of one flattened pair."""
    result: dict[str, tuple[float, float, float]] = {}
    for role, side_sign in (("arm_l", -1), ("arm_r", 1)):
        chains: dict[str, list[str]] = {}
        for name in roles.get(role, []):
            match = re.match(r"([LR]_arm\d+)_", name, re.IGNORECASE)
            if match:
                chains.setdefault(match.group(1).lower(), []).append(name)
        for chain_index, chain_name in enumerate(sorted(chains)):
            chain = chains[chain_name]
            pair_direction = -1 if chain_index % 2 else 1
            chain_phase = phase + chain_index * 0.85
            for bone_index, name in enumerate(chain):
                progress = bone_index / max(1, len(chain) - 1)
                tip_weight = 0.38 + progress * 0.62
                wave = math.sin(chain_phase + progress * math.pi * 1.65)
                result[name] = (
                    lift * pair_direction * (1 - progress * 0.45),
                    side_sign * sweep * tip_weight * 0.42,
                    side_sign * (curl * wave + sweep * pair_direction * 0.22) * tip_weight,
                )
    return result


def wraith_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
    neutral: dict[str, tuple[float, float, float]] = {}
    idle_a = wraith_tentacles(roles, sweep=3.5, curl=5.5, lift=1.5, phase=0.2)
    idle_b = wraith_tentacles(roles, sweep=-3.5, curl=5.5, lift=-1.5, phase=math.pi + 0.2)
    drift_a = wraith_tentacles(roles, sweep=6, curl=8, lift=2.5, phase=0.65)
    drift_b = wraith_tentacles(roles, sweep=-6, curl=8, lift=-2.5, phase=math.pi + 0.65)
    windup = wraith_tentacles(roles, sweep=-8, curl=10, lift=-4, phase=1.1)
    lash = wraith_tentacles(roles, sweep=15, curl=17, lift=7, phase=2.35)
    recoil = wraith_tentacles(roles, sweep=-11, curl=13, lift=-6, phase=1.75)
    collapse = wraith_tentacles(roles, sweep=-14, curl=7, lift=18, phase=math.pi * 0.5)
    return {
        "Idle": [(1, neutral), (16, merge(idle_a, rotate_role(roles, "body", (0, 0, 2), 3))), (31, neutral), (46, merge(idle_b, rotate_role(roles, "body", (0, 0, -2), 3))), (60, neutral)],
        "Move": [(1, drift_a), (10, merge(idle_a, rotate_role(roles, "body", (0, 0, 5), 3))), (19, drift_b), (28, merge(idle_b, rotate_role(roles, "body", (0, 0, -5), 3))), (36, drift_a)],
        "Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-12, 0, 0), 3), windup)), (16, merge(rotate_role(roles, "body", (18, 0, 0), 3), lash, rotate_role(roles, "jaw", (24, 0, 0), 3))), (25, drift_a), (36, neutral)],
        "HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 14), 3), recoil)), (13, merge(rotate_role(roles, "body", (7, 0, -6), 3), idle_a)), (24, neutral)],
        "Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-22, 0, 20), 3), recoil)), (44, merge(rotate_role(roles, "body", (18, 55, 72), 3), collapse, rotate_role(roles, "jaw", (30, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (18, 55, 72), 3), collapse))],
    }


def create_actions(profile: AssetProfile, rig: bpy.types.Object, roles: dict[str, list[str]]) -> dict[str, dict[str, object]]:
    pose_factory = {
        "dragon": dragon_poses,
        "guardian": guardian_poses,
        "insect": insect_poses,
        "wraith": wraith_poses,
    }[profile.creature]
    if profile.asset_id == "blue-eyes-ultimate-dragon":
        definitions = ultimate_dragon_poses(roles)
    elif profile.red_eyes_roles:
        definitions = red_eyes_dragon_poses(roles)
    else:
        definitions = pose_factory(roles)
    rig.animation_data_create()
    metadata = {}
    for clip_name, poses in definitions.items():
        action = bpy.data.actions.new(clip_name)
        action.use_fake_user = True
        action.use_frame_range = True
        action.frame_start = 1
        action.frame_end = CLIP_SPECS[clip_name]["frames"]
        action.use_cyclic = CLIP_SPECS[clip_name]["loop"]
        rig.animation_data.action = action
        animated_names = sorted({name for _, rotations in poses for name in rotations})
        for frame, rotations in poses:
            for name in animated_names:
                bone = rig.pose.bones.get(name)
                if not bone:
                    continue
                bone.rotation_mode = "XYZ"
                degrees = rotations.get(name, (0.0, 0.0, 0.0))
                bone.rotation_euler = tuple(math.radians(component) for component in degrees)
                bone.keyframe_insert("rotation_euler", frame=frame, group=bone.name)
        metadata[clip_name] = {
            "frames": CLIP_SPECS[clip_name]["frames"],
            "seconds": CLIP_SPECS[clip_name]["frames"] / FPS,
            "loop": CLIP_SPECS[clip_name]["loop"],
        }
    rig.animation_data.action = None
    for bone in rig.pose.bones:
        bone.rotation_euler = (0, 0, 0)
    return metadata


def select_only(objects: list[bpy.types.Object]) -> None:
    bpy.ops.object.select_all(action="DESELECT")
    for obj in objects:
        obj.select_set(True)
    bpy.context.view_layer.objects.active = objects[0]


def world_bounds(meshes: list[bpy.types.Object]) -> tuple[Vector, Vector]:
    depsgraph = bpy.context.evaluated_depsgraph_get()
    points: list[Vector] = []
    for mesh in meshes:
        evaluated = mesh.evaluated_get(depsgraph)
        evaluated_mesh = evaluated.to_mesh()
        points.extend(evaluated.matrix_world @ vertex.co for vertex in evaluated_mesh.vertices)
        evaluated.to_mesh_clear()
    minimum = Vector(tuple(min(point[index] for point in points) for index in range(3)))
    maximum = Vector(tuple(max(point[index] for point in points) for index in range(3)))
    return minimum, maximum


def look_at(obj: bpy.types.Object, target: Vector) -> None:
    obj.rotation_euler = (target - obj.location).to_track_quat("-Z", "Y").to_euler()


def render_preview(profile: AssetProfile, rig: bpy.types.Object, meshes: list[bpy.types.Object], out_path: Path, frame: int = 17) -> None:
    rig.animation_data.action = bpy.data.actions["Attack"]
    bpy.context.scene.frame_set(frame)
    bpy.context.view_layer.update()
    minimum, maximum = world_bounds(meshes)
    center = (minimum + maximum) * 0.5
    radius = (maximum - minimum).length * 0.5
    size = radius * 2
    scene = bpy.context.scene
    scene.render.engine = "BLENDER_EEVEE"
    scene.render.resolution_x = 640
    scene.render.resolution_y = 640
    scene.render.resolution_percentage = 100
    scene.render.image_settings.file_format = "PNG"
    scene.render.film_transparent = False
    scene.world.color = (0.008, 0.012, 0.02)
    camera_data = bpy.data.cameras.new("PreviewCamera")
    camera = bpy.data.objects.new("PreviewCamera", camera_data)
    bpy.context.collection.objects.link(camera)
    camera.data.lens = 65
    view_direction = Vector((0.8, -1.05, 0.42)).normalized()
    half_fov = math.atan(camera.data.sensor_width / (2 * camera.data.lens))
    camera.location = center + view_direction * (radius / math.sin(half_fov) * 0.45)
    look_at(camera, center)
    scene.camera = camera
    for name, offset, color, energy, radius in (
        ("Key", (1.4, -1.2, 1.7), (1.0, 0.52, 0.28), 1000, 5.0),
        ("Fill", (-1.3, -0.4, 0.8), (0.22, 0.42, 1.0), 800, 4.0),
        ("Rim", (0.3, 1.4, 1.3), (0.65, 0.22, 1.0), 900, 3.5),
    ):
        light_data = bpy.data.lights.new(name, "AREA")
        light_data.energy = energy
        light_data.color = color
        light_data.shape = "DISK"
        light_data.size = size * radius
        light = bpy.data.objects.new(name, light_data)
        light.location = center + Vector(offset) * size
        bpy.context.collection.objects.link(light)
        look_at(light, center)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    scene.render.filepath = str(out_path)
    bpy.ops.render.render(write_still=True)
    rig.animation_data.action = None


def export_asset(profile: AssetProfile, rig: bpy.types.Object, meshes: list[bpy.types.Object], source_path: Path, clips: dict[str, dict[str, object]], roles: dict[str, list[str]]) -> None:
    source_dir = MODEL_ROOT / profile.asset_id
    output_path = source_dir / f"{profile.asset_id}-animated.glb"
    select_only([rig, *meshes])
    bpy.ops.export_scene.gltf(
        filepath=str(output_path),
        export_format="GLB",
        use_selection=True,
        export_animations=True,
        export_animation_mode="ACTIONS",
        export_frame_range=True,
        export_skins=True,
        export_morph=True,
        export_yup=True,
        export_apply=False,
        export_cameras=False,
        export_lights=False,
        export_image_format="AUTO",
    )
    triangles = sum(len(polygon.vertices) - 2 for mesh in meshes for polygon in mesh.data.polygons)
    metadata = {
        "name": profile.display_name,
        "assetId": profile.asset_id,
        "source": source_path.name,
        "format": "glTF 2.0 binary (GLB)",
        "authoringTool": bpy.app.version_string,
        "fps": FPS,
        "trianglesApprox": triangles,
        "animations": clips,
        "animatedBones": {role: names for role, names in roles.items() if names},
    }
    (source_dir / f"{profile.asset_id}-animated.asset.json").write_text(json.dumps(metadata, indent=2) + "\n")
    render_preview(profile, rig, meshes, source_dir / "previews" / f"{profile.asset_id}-attack.png")
    print(f"BUILT={profile.asset_id} BONES={len(rig.data.bones)} TRIANGLES={triangles} CLIPS={','.join(clips)}")


def main() -> None:
    bpy.context.preferences.filepaths.save_version = 0
    bpy.context.scene.render.fps = FPS
    requested_ids = set(sys.argv[sys.argv.index("--") + 1:]) if "--" in sys.argv else set()
    unknown_ids = requested_ids - {profile.asset_id for profile in PROFILES}
    if unknown_ids:
        raise RuntimeError(f"Unknown asset ids: {', '.join(sorted(unknown_ids))}")
    selected_profiles = [profile for profile in PROFILES if not requested_ids or profile.asset_id in requested_ids]
    for profile in selected_profiles:
        reset_scene()
        rig, meshes, source_path = convert_and_import(profile)
        roles = build_roles(profile, rig)
        clips = create_actions(profile, rig, roles)
        export_asset(profile, rig, meshes, source_path, clips, roles)


if __name__ == "__main__":
    main()
