#!/usr/bin/env python3
"""
WoW 3.3.5 (WotLK, M2 v264) → GLTF 2.0 (.glb) converter.

Usage:
    python m2_to_gltf.py path/to/Model.M2 [output_dir]

Produces <output_dir>/<ModelName>.glb with:
  - Skinned mesh (all skin sections as primitives)
  - Full bone hierarchy with bind poses
  - All animation sequences as named GLTF animations
"""

import io
import json
import math
import os
import struct
import sys
from pathlib import Path

try:
    from PIL import Image as _PIL_Image
    _PIL_OK = True
except ImportError:
    _PIL_OK = False

# ─────────────────────────────────────────────────────────────────────────────
# BLP2 decoder  →  raw RGBA bytes (PIL required for DXT)
# ─────────────────────────────────────────────────────────────────────────────

def _blp_to_rgba(path: Path):
    """
    Decode a BLP2 file to (width, height, rgba_bytes).
    Supports palette (compression=1) and DXT (compression=2).
    Returns None if decoding fails or PIL is unavailable for DXT.
    """
    try:
        data = path.read_bytes()
    except OSError:
        return None

    if data[:4] not in (b'BLP2', b'BLP1'):
        return None

    # Header layout (C++ BlpStructure.h):
    # signature(4) version(4) compression(1) alphaDepth(1) alphaCompression(1)
    # mipLevels(1) width(4) height(4) offsets[16](64) sizes[16](64) = 148 bytes
    compression   = data[8]    # 1=palette, 2=DXT, 3=raw RGBA
    alpha_depth   = data[9]    # 0/1/4/8 bits
    alpha_compr   = data[10]   # for DXT: 0=DXT1, 1=DXT3, 7=DXT3 variant
    width         = struct.unpack_from('<I', data, 12)[0]
    height        = struct.unpack_from('<I', data, 16)[0]
    mip_offsets   = struct.unpack_from('<16I', data, 20)
    mip_sizes     = struct.unpack_from('<16I', data, 84)

    if width == 0 or height == 0 or not mip_offsets[0]:
        return None

    mip_data = data[mip_offsets[0]: mip_offsets[0] + mip_sizes[0]]

    # ── Palette (compression == 1) ────────────────────────────────────────
    if compression == 1:
        # BGRA palette of 256 entries at offset 148
        pal_raw = data[148: 148 + 1024]
        n = width * height
        indices = mip_data[:n]
        rgba = bytearray(n * 4)

        if alpha_depth == 8:
            # Color indices followed immediately by alpha bytes
            alpha_bytes = mip_data[n: n + n]
            for i in range(n):
                idx = indices[i]
                b, g, r = pal_raw[idx*4], pal_raw[idx*4+1], pal_raw[idx*4+2]
                a = alpha_bytes[i] if i < len(alpha_bytes) else 255
                rgba[i*4], rgba[i*4+1], rgba[i*4+2], rgba[i*4+3] = r, g, b, a
        elif alpha_depth == 4:
            alpha_src = mip_data[n:]
            for i in range(n):
                idx = indices[i]
                b, g, r = pal_raw[idx*4], pal_raw[idx*4+1], pal_raw[idx*4+2]
                byte_idx = i >> 1
                a4 = (alpha_src[byte_idx] >> (4 * (i & 1))) & 0xF if byte_idx < len(alpha_src) else 0xF
                a = a4 | (a4 << 4)
                rgba[i*4], rgba[i*4+1], rgba[i*4+2], rgba[i*4+3] = r, g, b, a
        elif alpha_depth == 1:
            alpha_src = mip_data[n:]
            for i in range(n):
                idx = indices[i]
                b, g, r = pal_raw[idx*4], pal_raw[idx*4+1], pal_raw[idx*4+2]
                byte_idx = i >> 3
                bit = (alpha_src[byte_idx] >> (i & 7)) & 1 if byte_idx < len(alpha_src) else 1
                a = 255 if bit else 0
                rgba[i*4], rgba[i*4+1], rgba[i*4+2], rgba[i*4+3] = r, g, b, a
        else:
            # alpha_depth == 0: fully opaque
            for i in range(n):
                idx = indices[i]
                b, g, r = pal_raw[idx*4], pal_raw[idx*4+1], pal_raw[idx*4+2]
                rgba[i*4], rgba[i*4+1], rgba[i*4+2], rgba[i*4+3] = r, g, b, 255

        return width, height, bytes(rgba)

    # ── DXT (compression == 2) ─────────────────────────────────────────────
    if compression == 2:
        if not _PIL_OK:
            return None

        # Map alphaCompression → DDS fourCC
        # 0=DXT1, 1=DXT3(premul), 7=DXT5, 9=DXT5
        fourcc_map = {0: b'DXT1', 1: b'DXT3', 7: b'DXT5', 9: b'DXT5'}
        fourcc = fourcc_map.get(alpha_compr, b'DXT1')

        # Build a minimal DDS file in memory
        DDSD_FLAGS = 0x1 | 0x2 | 0x4 | 0x1000 | 0x80000  # caps,h,w,pf,linearsize
        DDSCAPS_TEXTURE = 0x1000
        DDPF_FOURCC = 0x4

        header  = struct.pack('<4sI', b'DDS ', 124)
        header += struct.pack('<I', DDSD_FLAGS)
        header += struct.pack('<II', height, width)
        header += struct.pack('<I', mip_sizes[0])  # linearSize
        header += struct.pack('<II', 0, 0)          # depth, mipMapCount
        header += b'\x00' * 44                      # reserved
        # DDS_PIXELFORMAT
        header += struct.pack('<II', 32, DDPF_FOURCC)
        header += fourcc
        header += struct.pack('<IIIII', 0, 0, 0, 0, 0)
        # Caps
        header += struct.pack('<IIII', DDSCAPS_TEXTURE, 0, 0, 0)
        header += struct.pack('<I', 0)

        dds_bytes = header + mip_data
        try:
            img = _PIL_Image.open(io.BytesIO(dds_bytes))
            img = img.convert('RGBA')
            return img.width, img.height, img.tobytes()
        except Exception:
            return None

    # ── Raw RGBA (compression == 3) ────────────────────────────────────────
    if compression == 3:
        n = width * height
        rgba = bytearray(n * 4)
        # Stored as BGRA uint32 each
        for i in range(n):
            b, g, r, a = mip_data[i*4], mip_data[i*4+1], mip_data[i*4+2], mip_data[i*4+3]
            rgba[i*4], rgba[i*4+1], rgba[i*4+2], rgba[i*4+3] = r, g, b, a
        return width, height, bytes(rgba)

    return None


def _rgba_to_png_bytes(width: int, height: int, rgba: bytes) -> bytes:
    """Encode raw RGBA bytes to PNG using PIL."""
    img = _PIL_Image.frombytes('RGBA', (width, height), rgba)
    buf = io.BytesIO()
    img.save(buf, format='PNG', optimize=False)
    return buf.getvalue()


def _find_blp(m2_dir: Path, name: str) -> Path | None:
    """Case-insensitive BLP lookup in m2_dir."""
    name_lower = name.lower()
    for p in m2_dir.iterdir():
        if p.suffix.lower() == '.blp' and p.stem.lower() == Path(name_lower).stem.lower():
            return p
    return None

# ─────────────────────────────────────────────────────────────────────────────
# WoW animation sequence ID → name table (WotLK 3.3.5)
# ─────────────────────────────────────────────────────────────────────────────
ANIM_NAMES = {
    0: "Stand", 1: "Death", 2: "Spell", 3: "Stop", 4: "Walk", 5: "Run",
    6: "Dead", 7: "Rise", 8: "StandWound", 9: "CombatWound", 10: "CombatCritical",
    11: "ShuffleLeft", 12: "ShuffleRight", 13: "WalkBackwards", 14: "Stun",
    15: "HandsClosed", 16: "AttackUnarmed", 17: "Attack1H", 18: "Attack2H",
    19: "Attack2HL", 20: "ParryUnarmed", 21: "Parry1H", 22: "Parry2H",
    23: "Parry2HL", 24: "ShieldBlock", 25: "ReadyUnarmed", 26: "Ready1H",
    27: "Ready2H", 28: "Ready2HL", 29: "ReadyBow", 30: "Dodge",
    31: "SpellPrecast", 32: "SpellCast", 33: "SpellCastArea", 34: "NPCWelcome",
    35: "NPCGoodbye", 36: "Block", 37: "JumpStart", 38: "Jump", 39: "JumpEnd",
    40: "Fall", 41: "SwimIdle", 42: "Swim", 43: "SwimLeft", 44: "SwimRight",
    45: "SwimBackwards", 46: "AttackBow", 47: "FireBow", 48: "ReadyRifle",
    49: "AttackRifle", 50: "Loot", 51: "ReadySpellDirected", 52: "ReadySpellOmni",
    53: "SpellCastDirected", 54: "SpellCastOmni", 55: "BattleRoar",
    56: "ReadyAbility", 57: "Special1H", 58: "Special2H", 59: "ShieldBash",
    60: "EmoteCheer", 61: "EmoteTalk", 62: "EmoteAnswer", 63: "EmoteBeg",
    64: "EmoteEat", 65: "Mount", 66: "EmoteMount", 67: "EmoteAttackUnarmed",
    68: "EmoteAttack1H", 69: "EmoteAttack2H", 70: "EmoteAttack2HL",
    71: "EmoteParryUnarmed", 72: "EmoteParry1H", 73: "EmoteParry2H",
    74: "EmoteParry2HL", 75: "EmoteShieldBlock", 76: "SpellCastOmniUp",
    77: "InteractCrouch", 78: "Death2", 79: "AscentLoop", 80: "DescentLoop",
    81: "Hover", 82: "FlyFall", 83: "FlySwimIdle", 84: "FlySwim",
    85: "FlySwimLeft", 86: "FlySwimRight", 87: "FlySwimBackwards",
    88: "AttackBow_NoPitch", 89: "FireBow_NoPitch",
}

# ─────────────────────────────────────────────────────────────────────────────
# Math helpers
# ─────────────────────────────────────────────────────────────────────────────

def quat_mul(a, b):
    ax, ay, az, aw = a
    bx, by, bz, bw = b
    return (
        aw*bx + ax*bw + ay*bz - az*by,
        aw*by - ax*bz + ay*bw + az*bx,
        aw*bz + ax*by - ay*bx + az*bw,
        aw*bw - ax*bx - ay*by - az*bz,
    )

def quat_norm(q):
    x, y, z, w = q
    m = math.sqrt(x*x + y*y + z*z + w*w)
    if m < 1e-10:
        return (0.0, 0.0, 0.0, 1.0)
    return (x/m, y/m, z/m, w/m)

def quat_conj(q):
    x, y, z, w = q
    return (-x, -y, -z, w)

def vec3_transform_quat(v, q):
    """Rotate vector v by quaternion q."""
    # t = 2 * cross(q.xyz, v)
    qx, qy, qz, qw = q
    vx, vy, vz = v
    tx = 2.0 * (qy*vz - qz*vy)
    ty = 2.0 * (qz*vx - qx*vz)
    tz = 2.0 * (qx*vy - qy*vx)
    return (
        vx + qw*tx + qy*tz - qz*ty,
        vy + qw*ty + qz*tx - qx*tz,
        vz + qw*tz + qx*ty - qy*tx,
    )

# WoW M2 model space → Godot (Y-up, right-handed)
# WoW M2: X=right, Y=forward (into screen), Z=up
# Godot:  X=right, Y=up,                   Z=backward
# So: gx=wx, gy=wz, gz=-wy
def wow_pos_to_gltf(x, y, z):
    return (x, z, -y)

def wow_quat_to_gltf(qx, qy, qz, qw):
    # Apply same basis change to quaternion
    # New basis: (1,0,0), (0,0,-1), (0,1,0) in old basis
    # This corresponds to rotating by 90° around X then adjusting
    # Effectively: new_q = basis_q * old_q * basis_q_conj
    # where basis_q rotates (0,1,0) → (0,0,1): that's 90° around X = (sin45, 0, 0, cos45)
    # Simpler: just remap components
    # If v' = (vx, vz, -vy) = M*v, and q rotates v, then q' = M * q * M^-1
    # M = [[1,0,0],[0,0,1],[0,-1,0]]
    # New quat: x'=qx, y'=qz, z'=-qy, w'=qw
    return (qx, qz, -qy, qw)

def wow_scale_to_gltf(sx, sy, sz):
    # Scale is symmetric, just remap axes
    return (sx, sz, sy)

# ─────────────────────────────────────────────────────────────────────────────
# M2 binary parser
# ─────────────────────────────────────────────────────────────────────────────

class M2Parser:
    def __init__(self, path):
        self.path = Path(path)
        self.dir  = self.path.parent
        self.stem = self.path.stem

        with open(path, 'rb') as f:
            self.data = f.read()

        # Load per-animation external .anim buffers: anim_index → bytes
        self.anim_buffers = {}

        self._parse()

    # ── low-level helpers ────────────────────────────────────────────────────

    def _u(self, fmt, off):
        return struct.unpack_from(fmt, self.data, off)

    def _read_array(self, off):
        """Read M2Array {count, offset} at file position off. Returns (count, data_ofs, next_off)."""
        count, data_ofs = struct.unpack_from('<II', self.data, off)
        return count, data_ofs, off + 8

    def _read_track(self, off, buf_for_anim=None):
        """
        Parse M2Track header starting at `off`.
        Returns (track_dict, next_off) where track_dict has:
          interp, global_seq, timestamps[anim_idx], values[anim_idx]
        timestamps/values are lazy – only header parsed here.
        """
        interp, global_seq = struct.unpack_from('<hh', self.data, off); off += 4
        n_ts, ofs_ts, off = self._read_array(off)
        n_vs, ofs_vs, off = self._read_array(off)
        return {
            'interp': interp,
            'global_seq': global_seq,
            '_n_ts': n_ts, '_ofs_ts': ofs_ts,
            '_n_vs': n_vs, '_ofs_vs': ofs_vs,
        }, off

    def _resolve_track(self, track, anim_idx, value_fmt):
        """
        Read actual timestamps+values for animation index anim_idx from this track.
        value_fmt: 'vec3' | 'quat_c' (compressed int16×4) | 'float'
        Returns (timestamps: list[int], values: list[tuple])
        """
        n_ts   = track['_n_ts']
        ofs_ts = track['_ofs_ts']
        n_vs   = track['_n_vs']
        ofs_vs = track['_ofs_vs']

        if anim_idx >= n_ts or anim_idx >= n_vs:
            return [], []

        # Get the inner M2Array for this animation index
        # Timestamps outer array: array of M2Array<uint32>
        inner_ts_n, inner_ts_ofs = struct.unpack_from('<II', self.data, ofs_ts + anim_idx * 8)
        inner_vs_n, inner_vs_ofs = struct.unpack_from('<II', self.data, ofs_vs + anim_idx * 8)

        if inner_ts_n == 0 or inner_vs_n == 0:
            return [], []

        # Choose buffer: external .anim file or M2 file
        buf = self.anim_buffers.get(anim_idx, self.data)

        # Safety: offset 0 in external buffer means no data
        if inner_ts_ofs == 0 and buf is not self.data:
            return [], []

        try:
            timestamps = list(struct.unpack_from(f'<{inner_ts_n}I', buf, inner_ts_ofs))
        except struct.error:
            return [], []

        values = []
        try:
            if value_fmt == 'vec3':
                for j in range(inner_vs_n):
                    x, y, z = struct.unpack_from('<3f', buf, inner_vs_ofs + j * 12)
                    values.append((x, y, z))
            elif value_fmt == 'quat_c':
                # WoW M2 CompQuat: 4 × uint16 (unsigned), biased by 0x8000.
                # Mapping: 0x0000 → -1.0,  0x8000 → 0.0,  0xFFFF → +1.0
                # Formula: (uint16 - 0x8000) / 0x7FFF
                # NOTE: must read as '<4H' (unsigned), NOT '<4h' (signed int16).
                for j in range(inner_vs_n):
                    x, y, z, w = struct.unpack_from('<4H', buf, inner_vs_ofs + j * 8)
                    values.append((
                        (x - 0x8000) / 32767.0,
                        (y - 0x8000) / 32767.0,
                        (z - 0x8000) / 32767.0,
                        (w - 0x8000) / 32767.0,
                    ))
            elif value_fmt == 'float':
                for j in range(inner_vs_n):
                    v, = struct.unpack_from('<f', buf, inner_vs_ofs + j * 4)
                    values.append((v,))
        except struct.error:
            pass

        return timestamps, values

    # ── main parse ───────────────────────────────────────────────────────────

    def _parse(self):
        d = self.data

        # Magic + version
        magic = d[0:4]
        if magic != b'MD20':
            raise ValueError(f"Not an M2 file (magic={magic!r})")
        version, = struct.unpack_from('<I', d, 4)
        if version != 264:
            raise ValueError(f"Unsupported M2 version {version} (expected 264 for WotLK)")

        off = 8

        # ── Header fields ─────────────────────────────────────────────────

        # name
        n_name, ofs_name, off = self._read_array(off)

        # globalFlags
        self.global_flags, = struct.unpack_from('<I', d, off); off += 4

        # globalSequences
        self.n_gseq, self.ofs_gseq, off = self._read_array(off)

        # animations
        self.n_anim, self.ofs_anim, off = self._read_array(off)

        # animationLookup
        self.n_alook, self.ofs_alook, off = self._read_array(off)

        # bones
        self.n_bones, self.ofs_bones, off = self._read_array(off)

        # keyBoneLookup
        n_kbl, ofs_kbl, off = self._read_array(off)

        # vertices
        self.n_verts, self.ofs_verts, off = self._read_array(off)

        # numSkinProfiles (uint32, NOT M2Array)
        self.n_skins, = struct.unpack_from('<I', d, off); off += 4

        # colors(8) textures(8) textureWeights(8) textureTransforms(8)
        # replaceableTextureLookup(8) materials(8) boneLookupTable(8)
        # textureLookupTable(8) texUnitLookupTable(8) transparencyLookupTable(8)
        # textureTransformLookupTable(8)  → 11 × 8 = 88 bytes total
        self.n_tex,      self.ofs_tex,      _ = self._read_array(off + 8)    # textures
        self.n_mat,      self.ofs_mat,      _ = self._read_array(off + 5*8)  # materials
        self.n_tex_look, self.ofs_tex_look, _ = self._read_array(off + 7*8)  # textureLookupTable
        off += 11 * 8

        # boundingBox (CAaBox = 2 × Vec3 = 24), boundingSphereRadius (float)
        off += 24 + 4
        # collisionBox (24), collisionSphereRadius (4)
        off += 24 + 4

        # collision triangles/vertices/normals  (3 × 8)
        off += 3 * 8

        # attachments, attachmentLookup, events, lights, cameras,
        # cameraLookup, ribbonEmitters, particleEmitters  (8 × 8)
        off += 8 * 8

        # ── Load global sequences ─────────────────────────────────────────
        self.global_sequences = list(
            struct.unpack_from(f'<{self.n_gseq}I', d, self.ofs_gseq)
        ) if self.n_gseq > 0 else []

        # ── Load animation sequences ──────────────────────────────────────
        # M2Sequence: id(2) variationIndex(2) duration(4) movespeed(4)
        #             flags(4) frequency(2) _pad(2) replay(8) blendtime(4)
        #             bounds(24) radius(4) variationNext(2) aliasNext(2)
        # Total = 64 bytes
        SEQ_SIZE = 64
        self.sequences = []
        for i in range(self.n_anim):
            base = self.ofs_anim + i * SEQ_SIZE
            seq_id, var_idx, duration, movespeed, flags, frequency = \
                struct.unpack_from('<HHIfIh', d, base)
            # aliasNext at offset 62 of the sequence
            alias_next, = struct.unpack_from('<H', d, base + 62)
            self.sequences.append({
                'id': seq_id,
                'variation': var_idx,
                'duration': duration,   # ms
                'flags': flags,
                'alias_next': alias_next,
                'index': i,
            })

        # ── Load external .anim files ─────────────────────────────────────
        for i, seq in enumerate(self.sequences):
            anim_id  = seq['id']
            anim_var = seq['variation']
            anim_file = self.dir / f"{self.stem}{anim_id:04d}-{anim_var:02d}.anim"
            if anim_file.exists():
                with open(anim_file, 'rb') as f:
                    self.anim_buffers[i] = f.read()
                # print(f"  Loaded external anim {anim_id} var{anim_var}: {anim_file.name}")

        # ── Load vertices ─────────────────────────────────────────────────
        # M2Vertex: pos(12) boneWeights(4) boneIndices(4) normal(12) texCoords(16)  = 48
        VERT_SIZE = 48
        self.vertices = []
        for i in range(self.n_verts):
            base = self.ofs_verts + i * VERT_SIZE
            px, py, pz = struct.unpack_from('<3f', d, base)
            bw = struct.unpack_from('<4B', d, base + 12)
            bi = struct.unpack_from('<4B', d, base + 16)
            nx, ny, nz = struct.unpack_from('<3f', d, base + 20)
            u0, v0, u1, v1 = struct.unpack_from('<4f', d, base + 32)
            self.vertices.append({
                'pos': (px, py, pz),
                'bone_weights': bw,
                'bone_indices': bi,
                'normal': (nx, ny, nz),
                'uv0': (u0, v0),
            })

        # ── Load bones ────────────────────────────────────────────────────
        # M2CompBone (WotLK): keyBoneId(4) flags(4) parentBoneId(2) submeshId(2)
        #   bone_name_crc(4)  ← present in all WotLK builds
        #   translation: M2Track<Vec3>    = 20 bytes
        #   rotation:    M2Track<CompQuat>= 20 bytes
        #   scale:       M2Track<Vec3>    = 20 bytes
        #   pivot:       Vec3             = 12 bytes
        # Total = 4+4+2+2+4 + 20+20+20 + 12 = 88 bytes
        BONE_SIZE = 88
        self.bones = []
        for i in range(self.n_bones):
            base = self.ofs_bones + i * BONE_SIZE
            key_bone_id, flags = struct.unpack_from('<iI', d, base)
            parent, submesh = struct.unpack_from('<hH', d, base + 8)
            boff = base + 16  # +4 for bone_name_crc
            t_track, boff = self._read_track(boff)
            r_track, boff = self._read_track(boff)
            s_track, boff = self._read_track(boff)
            px, py, pz = struct.unpack_from('<3f', d, boff)
            self.bones.append({
                'key_id': key_bone_id,
                'flags': flags,
                'parent': parent,     # -1 = root
                'pivot': (px, py, pz),
                't_track': t_track,
                'r_track': r_track,
                's_track': s_track,
            })

        # ── Load textures ──────────────────────────────────────────────────
        # M2Texture: type(4) flags(4) filename M2Array(8) = 16 bytes
        TEX_SIZE = 16
        self.textures = []
        for i in range(self.n_tex):
            base = self.ofs_tex + i * TEX_SIZE
            tex_type, tex_flags = struct.unpack_from('<II', d, base)
            n_fn, ofs_fn = struct.unpack_from('<II', d, base + 8)
            filename = ""
            if n_fn > 0 and ofs_fn > 0:
                try:
                    raw = d[ofs_fn: ofs_fn + n_fn]
                    filename = raw.rstrip(b'\x00').decode('utf-8', errors='replace')
                except Exception:
                    pass
            self.textures.append({'type': tex_type, 'flags': tex_flags, 'filename': filename})

        # ── textureLookupTable: uint16 per entry → index into self.textures ─
        self.texture_lookup = list(
            struct.unpack_from(f'<{self.n_tex_look}H', d, self.ofs_tex_look)
        ) if self.n_tex_look > 0 else []

        # ── Load M2 materials ──────────────────────────────────────────────
        # M2Material: flags(uint16) + blendingMode(uint16) = 4 bytes each
        # blendingMode: 0=Opaque, 1=AlphaKey, 2=Alpha, 3=Add, 4=Mod, 5=Mod2x, 6=ModAdd, 7=InvSrcAlpha...
        self.materials_data = []
        MAT_SIZE = 4
        for i in range(self.n_mat):
            base = self.ofs_mat + i * MAT_SIZE
            mat_flags, blend_mode = struct.unpack_from('<HH', d, base)
            self.materials_data.append({'flags': mat_flags, 'blend_mode': blend_mode})

        print(f"M2: {self.n_verts} vertices, {self.n_bones} bones, "
              f"{self.n_anim} animations, {len(self.anim_buffers)} external anims, "
              f"{self.n_mat} materials")


# ─────────────────────────────────────────────────────────────────────────────
# Skin (.skin) parser
# ─────────────────────────────────────────────────────────────────────────────

class SkinParser:
    def __init__(self, path):
        with open(path, 'rb') as f:
            self.data = f.read()
        self._parse()

    def _parse(self):
        d = self.data
        magic = d[0:4]
        if magic != b'SKIN':
            raise ValueError(f"Not a skin file (magic={magic!r})")

        off = 4
        def ra(o):
            count, ofs = struct.unpack_from('<II', d, o)
            return count, ofs, o + 8

        n_v, ofs_v, off = ra(off)
        n_i, ofs_i, off = ra(off)
        n_b, ofs_b, off = ra(off)   # bone lookup table: uint16[] maps local→global bone index
        n_s, ofs_s, off = ra(off)   # skin sections (submeshes)
        n_bt, ofs_bt, off = ra(off) # render batches
        self.bone_count_max, = struct.unpack_from('<I', d, off)

        # Local vertex indices (into M2 global vertex list)
        self.local_vertices = list(struct.unpack_from(f'<{n_v}H', d, ofs_v))

        # Bone lookup table: vertex.bone_indices[j] → bone_lookup[j] → M2 global bone index
        self.bone_lookup = list(struct.unpack_from(f'<{n_b}H', d, ofs_b)) if n_b > 0 else []

        # Triangle indices (into local_vertices)
        self.indices = list(struct.unpack_from(f'<{n_i}H', d, ofs_i))

        # Skin sections / submeshes
        # M2SkinSection: skinSectionId(2) Level(2) vertexStart(2) vertexCount(2)
        #   indexStart(2) indexCount(2) boneCount(2) startBones(2) boneInfluences(2)
        #   centerBoneIndex(2) centerPos(12) sortCenterPos(12) sortRadius(4)
        # Total = 2+2+2+2+2+2+2+2+2+2+12+12+4 = 48 bytes
        SEC_SIZE = 48
        self.sections = []
        for i in range(n_s):
            base = ofs_s + i * SEC_SIZE
            (sec_id, level, vstart, vcount, istart, icount,
             bone_count, start_bones, bone_influences, center_bone) = \
                struct.unpack_from('<10H', d, base)
            self.sections.append({
                'id': sec_id,
                'vertex_start': vstart,
                'vertex_count': vcount,
                'index_start': istart,
                'index_count': icount,
            })

        # Render batches (texture units)
        # M2Batch (WotLK): flags(1) priorityPlane(1) shader_id(2)
        #   skinSectionIndex(2) geosetIndex(2) colorIndex(2) materialIndex(2)
        #   materialLayer(2) textureCount(2) textureLookupId(2)
        #   textureUnitLookupId(2) transparencyLookupId(2) textureAnimLookupId(2)
        # Total = 1+1+2+2+2+2+2+2+2+2+2+2+2 = 24 bytes
        # M2Batch (WotLK, 24 bytes):
        #   flags(1) priorityPlane(1) shader_id(2)
        #   skinSectionIndex(2) geosetIndex(2) colorIndex(2) materialIndex(2)
        #   materialLayer(2) textureCount(2) textureLookupId(2)
        #   textureUnitLookupId(2) transparencyLookupId(2) textureAnimLookupId(2)
        BATCH_SIZE = 24
        # Map skin section index → first texture_lookup_id / materialIndex found for it
        # Batch layout: flags(1) priority(1) shader_id(2) skinSectionIndex(2) geosetIndex(2)
        #               colorIndex(2) materialIndex(2) materialLayer(2) textureCount(2)
        #               textureLookupId(2) textureUnitLookupId(2) ...
        # offsets:       0              2           4               6
        #                8              10          12              14
        #                16             18           20
        self.section_texture_lookup  = {}  # sec_idx → tex_look_id
        self.section_material_index  = {}  # sec_idx → m2 material index
        for i in range(n_bt):
            base = ofs_bt + i * BATCH_SIZE
            (sec_idx,)     = struct.unpack_from('<H', d, base + 4)
            (mat_idx,)     = struct.unpack_from('<H', d, base + 10)  # materialIndex
            (tex_look_id,) = struct.unpack_from('<H', d, base + 16)  # textureLookupId
            if sec_idx not in self.section_texture_lookup:
                self.section_texture_lookup[sec_idx] = tex_look_id
            if sec_idx not in self.section_material_index:
                self.section_material_index[sec_idx] = mat_idx

        print(f"Skin: {n_v} local verts, {n_i//3} triangles, {n_s} sections")


# ─────────────────────────────────────────────────────────────────────────────
# Minimal GLTF 2.0 / GLB writer
# ─────────────────────────────────────────────────────────────────────────────

class GltfBuilder:
    def __init__(self):
        self.asset = {
            "version": "2.0",
            "generator": "WoW M2 to GLTF converter",
            "extras": {"openwc_m2_anim_schema": "pivot_prefix_v1"},
        }
        self.nodes        = []
        self.meshes       = []
        self.skins        = []
        self.accessors    = []
        self.buffer_views = []
        self.animations   = []
        self.materials    = []
        self.images       = []
        self.textures     = []
        self.bin_data     = bytearray()

    # ── binary buffer helpers ────────────────────────────────────────────────

    def _add_bin(self, data: bytes, alignment: int = 4) -> int:
        """Append data to bin buffer (aligned), return byte offset."""
        off = len(self.bin_data)
        pad = (alignment - off % alignment) % alignment
        self.bin_data.extend(b'\x00' * pad)
        off = len(self.bin_data)
        self.bin_data.extend(data)
        return off

    def _add_accessor(self, data: bytes, count: int, component_type: int,
                      acc_type: str, min_vals=None, max_vals=None,
                      normalized=False) -> int:
        off = self._add_bin(data)
        bv_idx = len(self.buffer_views)
        self.buffer_views.append({
            "buffer": 0,
            "byteOffset": off,
            "byteLength": len(data),
        })
        acc = {
            "bufferView": bv_idx,
            "byteOffset": 0,
            "componentType": component_type,
            "count": count,
            "type": acc_type,
        }
        if normalized:
            acc["normalized"] = True
        if min_vals is not None:
            acc["min"] = list(min_vals)
        if max_vals is not None:
            acc["max"] = list(max_vals)
        idx = len(self.accessors)
        self.accessors.append(acc)
        return idx

    # ── build scene ──────────────────────────────────────────────────────────

    def build(self, m2: M2Parser, skin: SkinParser, anim_filter=None) -> bytes:
        """
        Build GLB bytes.
        anim_filter: optional set of animation IDs to include (None = all).
        """
        # 1. Build bone node tree (returns all bone nodes + virtual Armature root)
        bone_node_indices, armature_idx = self._build_skeleton(m2)

        # 2. Resolve textures → GLTF material indices
        mat_map = self._build_materials(m2, skin)

        # 3. Build per-geoset mesh nodes
        geoset_node_indices = self._build_mesh(m2, skin, bone_node_indices, mat_map)

        # 4. Build skin; every geoset node shares the same skin
        skin_idx = self._build_skin(m2, bone_node_indices, armature_idx)
        for ni in geoset_node_indices:
            self.nodes[ni]["skin"] = skin_idx

        # 5. Build animations
        self._build_animations(m2, bone_node_indices, anim_filter)

        # 6. Compose scene – single Armature node + geoset mesh nodes
        scene_nodes = [armature_idx] + geoset_node_indices

        gltf = {
            "asset": self.asset,
            "scene": 0,
            "scenes": [{"nodes": scene_nodes}],
            "nodes":        self.nodes,
            "meshes":       self.meshes,
            "skins":        self.skins,
            "accessors":    self.accessors,
            "bufferViews":  self.buffer_views,
            "buffers":      [{"byteLength": len(self.bin_data)}],
        }
        if self.animations:
            gltf["animations"] = self.animations
        if self.materials:
            gltf["materials"] = self.materials
        if self.textures:
            gltf["textures"] = self.textures
        if self.images:
            gltf["images"] = self.images

        return self._write_glb(gltf)

    # ── texture / material builder ────────────────────────────────────────────

    def _build_materials(self, m2: M2Parser, skin: SkinParser) -> dict:
        """
        Returns a dict: section_index → gltf_material_index (or None).
        Textures are embedded as PNG in the binary buffer.

        WoW texture types used for character base appearance:
          0  = hardcoded filename (usually shared parts)
          1  = body skin  → {stem}Skin00_00.blp
          2  = object skin
          6  = hair
          8  = fur
          11 = skin extra (naked torso/legs) → {stem}NakedTorsoSkin00_00.blp / NakedPelvisSkin00_00.blp
        """
        if not _PIL_OK:
            return {}

        m2_dir = m2.path.parent

        # ── default skin BLP discovery ──────────────────────────────────────
        # Index BLP files in m2_dir (character-specific textures)
        blp_index = {}  # lowercase stem → Path
        for p in m2_dir.iterdir():
            if p.suffix.lower() == '.blp':
                blp_index[p.stem.lower()] = p

        # Also index race-level directory (one up from m2_dir) for shared
        # textures like hair (e.g. CHARACTER/BloodElf/HAIR00_00.BLP)
        race_blp_index = {}
        race_dir = m2_dir.parent
        if race_dir.is_dir():
            for p in race_dir.iterdir():
                if p.suffix.lower() == '.blp':
                    race_blp_index[p.stem.lower()] = p

        stem_lower = m2.stem.lower()

        def _default_for_type(tex_type):
            """Return a Path candidate for a given replaceable texture type."""
            if tex_type == 1:
                # Primary body skin: e.g. BloodElfFemaleSkin00_00.blp
                for k in blp_index:
                    if k.startswith(stem_lower + 'skin00_'):
                        return blp_index[k]
            elif tex_type == 11:
                # Naked torso or pelvis skin
                for k in blp_index:
                    if 'nakedtorsoskin' in k or 'nakedpelvisskin' in k:
                        return blp_index[k]
            elif tex_type == 6:
                # Hair: first look in char dir (e.g. BloodElfMaleHair01.blp),
                # then in race dir (e.g. CHARACTER/BloodElf/Hair00_00.BLP)
                for k in blp_index:
                    if 'hair' in k or 'scalp' in k:
                        return blp_index[k]
                for k in sorted(race_blp_index):
                    if 'hair' in k or 'scalp' in k:
                        return race_blp_index[k]
            elif tex_type == 2:
                # Object / cape skin – skip, equipment-specific
                return None
            return None

        # ── cache: blp_path → gltf image index ─────────────────────────────
        blp_cache: dict = {}  # key: Path or (str, Path) → gltf texture index

        def _load_texture(blp_path: Path) -> int | None:
            """Decode BLP and add to GLTF images/textures, return texture index."""
            if blp_path in blp_cache:
                return blp_cache[blp_path]
            result = _blp_to_rgba(blp_path)
            if result is None:
                blp_cache[blp_path] = None
                return None
            w, h, rgba = result
            png_bytes = _rgba_to_png_bytes(w, h, rgba)
            # Embed PNG in binary buffer
            png_off = self._add_bin(png_bytes, alignment=1)
            bv_idx = len(self.buffer_views)
            self.buffer_views.append({
                "buffer": 0,
                "byteOffset": png_off,
                "byteLength": len(png_bytes),
            })
            img_idx = len(self.images)
            self.images.append({
                "name": blp_path.stem,
                "mimeType": "image/png",
                "bufferView": bv_idx,
            })
            tex_idx = len(self.textures)
            self.textures.append({"source": img_idx})
            blp_cache[blp_path] = tex_idx
            return tex_idx

        def _load_skin_composited(blp_path: Path) -> int | None:
            """
            Load body skin BLP and composite all overlay textures for the naked state.

            WoW character skin UV layout (512x512):
              Upper-left panel (X:0-256, Y:0-128)   NakedTorsoSkin  – bra area
              Upper-left panel (X:0-256, Y:128-256)  NakedPelvisSkin – panties area
              Lower-left quadrant (X:0-256, Y:256-512) = head/face:
                FaceUpper (256x64)  at (0, 320)  – forehead, upper face
                FaceLower (256x128) at (0, 384)  – eyes, mouth, nose

            All layers are alpha-composited over the base skin in order.
            """
            cache_key = ("skin_composited", blp_path)
            if cache_key in blp_cache:
                return blp_cache[cache_key]

            skin_result = _blp_to_rgba(blp_path)
            if skin_result is None:
                blp_cache[cache_key] = None
                return None

            sw, sh, skin_rgba = skin_result
            skin_img = _PIL_Image.frombytes('RGBA', (sw, sh), skin_rgba)

            # ── 1. Naked body overlays (torso + pelvis = underwear) ──────────
            # NakedTorsoSkin (256x128) at (0, 0)   – upper-left, bra region
            # NakedPelvisSkin (256x128) at (0, 128) – below torso, panties region
            for key_frag, paste_x, paste_y in [
                ('nakedtorsoskin', 0, 0),
                ('nakedpelvisskin', 0, 128),
            ]:
                naked_path = None
                for k, p in blp_index.items():
                    if key_frag in k:
                        naked_path = p
                        break
                if naked_path is None:
                    continue
                naked_result = _blp_to_rgba(naked_path)
                if naked_result is None:
                    continue
                nw, nh, naked_rgba = naked_result
                naked_img = _PIL_Image.frombytes('RGBA', (nw, nh), naked_rgba)
                skin_img.alpha_composite(naked_img, (paste_x, paste_y))
                print(f"  Composited {naked_path.name} onto skin at ({paste_x}, {paste_y})")

            # ── 2. Face overlays ─────────────────────────────────────────────
            # FaceLower (256x128) at (0, 384)  – eyes, mouth, nose
            # FaceUpper (256x64)  at (0, 320)  – forehead/upper face
            face_lower_y = sh  # updated after FaceLower is measured

            for face_key_fragment in ('facelower', 'faceupper'):
                face_path = None
                for k, p in blp_index.items():
                    if face_key_fragment in k:
                        face_path = p
                        break
                if face_path is None:
                    continue
                face_result = _blp_to_rgba(face_path)
                if face_result is None:
                    continue
                fw, fh, face_rgba = face_result
                face_img = _PIL_Image.frombytes('RGBA', (fw, fh), face_rgba)
                paste_x = 0
                if face_key_fragment == 'facelower':
                    paste_y = sh - fh
                    face_lower_y = paste_y
                else:
                    paste_y = face_lower_y - fh
                skin_img.alpha_composite(face_img, (paste_x, paste_y))
                print(f"  Composited {face_path.name} onto skin at ({paste_x}, {paste_y})")

            buf = io.BytesIO()
            skin_img.save(buf, format='PNG', optimize=False)
            png_bytes = buf.getvalue()
            # Embed PNG in binary buffer
            png_off = self._add_bin(png_bytes, alignment=1)
            bv_idx = len(self.buffer_views)
            self.buffer_views.append({
                "buffer": 0,
                "byteOffset": png_off,
                "byteLength": len(png_bytes),
            })
            img_idx = len(self.images)
            self.images.append({
                "name": blp_path.stem,
                "mimeType": "image/png",
                "bufferView": bv_idx,
            })
            tex_idx = len(self.textures)
            self.textures.append({"source": img_idx})
            blp_cache[cache_key] = tex_idx
            return tex_idx

        # ── resolve texture for each M2 texture entry ───────────────────────
        # tex_idx → gltf texture idx
        m2tex_to_gltf: dict[int, int] = {}
        for i, tex in enumerate(m2.textures):
            blp_path = None
            if tex['type'] == 0 and tex['filename']:
                # Hardcoded path: extract filename stem, search in m2_dir first,
                # then sibling directories (WoW sometimes points cross-gender).
                fn_stem = Path(tex['filename'].replace('\\', '/')).stem.lower()
                blp_path = blp_index.get(fn_stem) or race_blp_index.get(fn_stem)
                if blp_path is None:
                    # Search sibling dirs (e.g. Female M2 refs Male eye glow)
                    for sibling in m2_dir.parent.iterdir():
                        if sibling.is_dir() and sibling != m2_dir:
                            for p in sibling.iterdir():
                                if p.suffix.lower() == '.blp' and p.stem.lower() == fn_stem:
                                    blp_path = p
                                    break
                        if blp_path:
                            break
            else:
                blp_path = _default_for_type(tex['type'])
            if blp_path:
                # Body skin (type=1) gets face overlay composited on top
                if tex['type'] == 1:
                    gtex = _load_skin_composited(blp_path)
                else:
                    gtex = _load_texture(blp_path)
                if gtex is not None:
                    m2tex_to_gltf[i] = gtex

        # ── map each skin section → gltf material idx ──────────────────────
        # Key: (gltf_tex_idx, m2_blend_mode) → mat_idx so blend_mode=1 (AlphaKey)
        # gets a MASK material and blend_mode=2 gets a BLEND material.
        mat_cache: dict[tuple, int] = {}

        def _get_or_create_mat(gtex_idx: int, blend_mode: int = 2) -> int:
            key = (gtex_idx, blend_mode)
            if key in mat_cache:
                return mat_cache[key]
            # Map M2 blend mode → GLTF alphaMode
            if blend_mode == 0:                 # Opaque
                alpha_mode = "OPAQUE"
            elif blend_mode == 1:               # AlphaKey (cutout)
                alpha_mode = "MASK"
            else:                               # Alpha, Add, Mod, etc.
                alpha_mode = "BLEND"
            mat = {
                "pbrMetallicRoughness": {
                    "baseColorTexture": {"index": gtex_idx},
                    "metallicFactor": 0.0,
                    "roughnessFactor": 1.0,
                },
                "alphaMode": alpha_mode,
                "doubleSided": True,
            }
            if blend_mode == 1:
                mat["alphaCutoff"] = 0.5
            mat_idx = len(self.materials)
            self.materials.append(mat)
            mat_cache[key] = mat_idx
            return mat_idx

        mat_map: dict[int, int] = {}  # section_idx → gltf_mat_idx
        for sec_idx, tex_look_id in skin.section_texture_lookup.items():
            # Determine this section's M2 blend mode from its material
            m2mat_idx = skin.section_material_index.get(sec_idx)
            blend_mode = 2  # default: alpha blend
            if m2mat_idx is not None and m2mat_idx < len(m2.materials_data):
                blend_mode = m2.materials_data[m2mat_idx]['blend_mode']
            if tex_look_id < len(m2.texture_lookup):
                m2tex_idx = m2.texture_lookup[tex_look_id]
                gtex = m2tex_to_gltf.get(m2tex_idx)
                if gtex is not None:
                    mat_map[sec_idx] = _get_or_create_mat(gtex, blend_mode)

        n_tex = len(self.images)
        n_mat = len(self.materials)
        if n_tex:
            print(f"Textures: {n_tex} embedded, {n_mat} materials")
        return mat_map

    # ── skeleton ─────────────────────────────────────────────────────────────

    def _build_skeleton(self, m2: M2Parser):
        bone_node_indices = []
        pivot_node_indices = []

        for i, bone in enumerate(m2.bones):
            pivot_idx = len(self.nodes)
            anim_idx = pivot_idx + 1
            pivot_node_indices.append(pivot_idx)
            bone_node_indices.append(anim_idx)

            # Pivot node holds the bind-pose offset. The child bone node stays
            # at identity and receives animation channels. This mirrors WoW's
            # T(pivot) * anim * T(-pivot) behavior closely enough for GLTF skinning.
            px, py, pz = wow_pos_to_gltf(*bone['pivot'])
            self.nodes.append({
                "name": f"bone_{i}_pivot",
                "translation": [px, py, pz],
                "children": [anim_idx],
            })
            self.nodes.append({
                "name": f"bone_{i}",
            })

        # Wire pivot nodes into the hierarchy.
        for i, bone in enumerate(m2.bones):
            parent = bone['parent']
            if parent >= 0 and parent < len(m2.bones):
                parent_node = bone_node_indices[parent]
                child_node  = pivot_node_indices[i]
                self.nodes[parent_node].setdefault("children", []).append(child_node)

        # Convert world-space pivots to parent-relative pivot translations.
        for i, bone in enumerate(m2.bones):
            parent = bone['parent']
            if parent >= 0 and parent < len(m2.bones):
                ppx, ppy, ppz = wow_pos_to_gltf(*m2.bones[parent]['pivot'])
                node = self.nodes[pivot_node_indices[i]]
                t = node["translation"]
                node["translation"] = [t[0]-ppx, t[1]-ppy, t[2]-ppz]

        # GLTF spec: skin.skeleton must be an ancestor of every joint node.
        # WoW models have multiple root bones (main skeleton + attachment/socket
        # bones each with parent=-1).  Wrap them all under a single virtual
        # "Armature" node at the origin so the skin has a valid common root.
        root_bone_nodes = [pivot_node_indices[i]
                           for i, b in enumerate(m2.bones) if b['parent'] < 0]
        armature_idx = len(self.nodes)
        self.nodes.append({
            "name": "Armature",
            "translation": [0.0, 0.0, 0.0],
            "rotation":    [0.0, 0.0, 0.0, 1.0],
            "scale":       [1.0, 1.0, 1.0],
            "children":    root_bone_nodes,
        })

        n_roots = len(root_bone_nodes)
        if n_roots > 1:
            print(f"Skeleton: {len(m2.bones)} bones, {n_roots} root bones "
                  f"-> wrapped under virtual Armature node")

        return bone_node_indices, armature_idx

    # ── mesh ─────────────────────────────────────────────────────────────────

    def _build_section_primitive(self, m2, skin, sec_idx, mat_map):
        """Build one GLTF primitive dict for skin section sec_idx. Returns None if empty."""
        sec = skin.sections[sec_idx]
        vstart = sec['vertex_start']
        vcount = sec['vertex_count']
        istart = sec['index_start']
        icount = sec['index_count']

        if vcount == 0 or icount == 0:
            return None

        local_vert_slice = skin.local_vertices[vstart: vstart + vcount]
        positions = []; normals = []; uvs = []; joints = []; weights = []

        for local_idx in range(vcount):
            global_vert_idx = local_vert_slice[local_idx]
            if global_vert_idx >= len(m2.vertices):
                continue
            v = m2.vertices[global_vert_idx]
            positions.extend(wow_pos_to_gltf(*v['pos']))
            normals.extend(wow_pos_to_gltf(*v['normal']))
            uvs.extend([v['uv0'][0], v['uv0'][1]])
            bw = v['bone_weights']
            bw_sum = sum(bw) or 1
            weights.extend([b / bw_sum for b in bw])
            # In WotLK M2, vertex bone_indices are direct M2 bone array indices.
            joints.extend(list(v['bone_indices']))

        tri_indices = [skin.indices[k] - vstart for k in range(istart, istart + icount)]

        if not positions or not tri_indices:
            return None

        n_verts = len(positions) // 3
        pos_bytes = struct.pack(f'<{len(positions)}f', *positions)
        nrm_bytes = struct.pack(f'<{len(normals)}f', *normals)
        uv_bytes  = struct.pack(f'<{len(uvs)}f', *uvs)
        jnt_bytes = struct.pack(f'<{len(joints)}H', *joints)
        wgt_bytes = struct.pack(f'<{len(weights)}f', *weights)
        idx_bytes = struct.pack(f'<{len(tri_indices)}H', *tri_indices)

        xs = positions[0::3]; ys = positions[1::3]; zs = positions[2::3]
        pos_acc = self._add_accessor(pos_bytes, n_verts, 5126, "VEC3",
                                     [min(xs), min(ys), min(zs)], [max(xs), max(ys), max(zs)])
        nrm_acc = self._add_accessor(nrm_bytes, n_verts, 5126, "VEC3")
        uv_acc  = self._add_accessor(uv_bytes,  n_verts, 5126, "VEC2")
        jnt_acc = self._add_accessor(jnt_bytes, n_verts, 5123, "VEC4")
        wgt_acc = self._add_accessor(wgt_bytes, n_verts, 5126, "VEC4")
        idx_acc = self._add_accessor(idx_bytes, len(tri_indices), 5123, "SCALAR")

        prim = {
            "attributes": {
                "POSITION":   pos_acc,
                "NORMAL":     nrm_acc,
                "TEXCOORD_0": uv_acc,
                "JOINTS_0":   jnt_acc,
                "WEIGHTS_0":  wgt_acc,
            },
            "indices": idx_acc,
            "mode": 4,
        }
        mat_idx = mat_map.get(sec_idx)
        if mat_idx is not None:
            prim["material"] = mat_idx
        return prim

    def _build_mesh(self, m2: M2Parser, skin: SkinParser, bone_node_indices, mat_map: dict = None):
        """
        Build one GLTF mesh node per unique geoset ID.

        WoW geoset ID convention:
          0        = base body (always visible)
          1xx      = skin body variation (cat=1, var=xx; show only var=01 by default)
          2xx      = face lower
          3xx      = face upper
          4xx      = hair style
          5xx      = facial feature (beard, etc.)
          6xx      = eyelashes
          7xx-9xx  = accessories, tabard, cloak
          1000+    = additional optional parts

        Each geoset becomes a separate MeshInstance3D in Godot so you can
        show/hide individual parts via GDScript.
        """
        if mat_map is None:
            mat_map = {}

        # Correct geoset ID ranges from M2SkinMeshPartID (WotLK 3.3.5)
        # Source: pywowlib/enums/m2_enums.py :: M2SkinMeshPartID
        GEOSET_RANGES = [
            (range(0,    1),    "skin"),           # base body – always shown
            (range(1,    35),   "hair"),            # hair styles
            (range(101,  125),  "facial1_beard"),
            (range(201,  220),  "facial2_mustache"),
            (range(301,  320),  "facial3_sideburns"),
            (range(401,  406),  "gloves"),
            (range(501,  511),  "boots"),
            (range(601,  615),  "shirt"),
            (range(701,  712),  "ears"),
            (range(801,  805),  "wristbands"),
            (range(901,  906),  "kneepads"),
            (range(1001, 1005), "chest"),
            (range(1101, 1106), "pants"),
            (range(1201, 1205), "tabard"),
            (range(1301, 1304), "legs"),
            (range(1401, 1415), "shirt_doublet"),
            (range(1501, 1525), "cape"),
            (range(1601, 1615), "facial_jewelry"),
            (range(1701, 1706), "eye_effects"),
            (range(1801, 1805), "belt"),
            (range(1901, 1915), "trail"),
            (range(2001, 2009), "feet"),
        ]

        def _geoset_name(gid: int) -> str:
            for r, name in GEOSET_RANGES:
                if gid in r:
                    variant = gid - r.start
                    return f"geoset_{gid:04d}_{name}_v{variant:02d}"
            return f"geoset_{gid:04d}_unknown"

        # Group sections by geoset ID
        geoset_sections: dict[int, list[int]] = {}
        for sec_idx, sec in enumerate(skin.sections):
            gid = sec['id']
            geoset_sections.setdefault(gid, []).append(sec_idx)

        geoset_node_indices = []
        category_set = set()
        for gid in sorted(geoset_sections):
            primitives = []
            for sec_idx in geoset_sections[gid]:
                prim = self._build_section_primitive(m2, skin, sec_idx, mat_map)
                if prim is not None:
                    primitives.append(prim)

            if not primitives:
                continue

            node_name = _geoset_name(gid)
            # Extract category name (part between second and third underscore)
            cat_name = node_name.split("_")[2] if node_name.count("_") >= 2 else "unknown"
            category_set.add(cat_name)

            # Determine M2 blend_mode for this geoset (use first section's material)
            blend_mode = 0
            for sec_idx in geoset_sections[gid]:
                mid = skin.section_material_index.get(sec_idx)
                if mid is not None and mid < len(m2.materials_data):
                    blend_mode = m2.materials_data[mid]['blend_mode']
                    break

            mesh_idx = len(self.meshes)
            self.meshes.append({"name": node_name, "primitives": primitives})

            node_idx = len(self.nodes)
            self.nodes.append({
                "name": node_name,
                "mesh": mesh_idx,
                "extras": {"geoset_id": gid, "blend_mode": blend_mode},
            })
            geoset_node_indices.append(node_idx)

        print(f"Geosets: {len(geoset_node_indices)} nodes, types: {sorted(category_set)}")
        return geoset_node_indices

    # ── skin (armature binding) ───────────────────────────────────────────────

    def _build_skin(self, m2: M2Parser, bone_node_indices, armature_idx: int):
        """Build inverse bind matrices and GLTF skin."""
        n_bones = len(m2.bones)

        ibm_floats = []
        for bone in m2.bones:
            # Inverse bind matrix for bone i:
            # At bind pose all rotations are identity, so world transform = T(pivot_world).
            # IBM = T(-pivot_world), column-major (GLTF convention).
            px, py, pz = wow_pos_to_gltf(*bone['pivot'])
            mat = [
                1.0,  0.0,  0.0,  0.0,
                0.0,  1.0,  0.0,  0.0,
                0.0,  0.0,  1.0,  0.0,
                -px, -py, -pz,  1.0,
            ]
            ibm_floats.extend(mat)

        ibm_bytes = struct.pack(f'<{len(ibm_floats)}f', *ibm_floats)
        ibm_acc = self._add_accessor(ibm_bytes, n_bones, 5126, "MAT4")

        skin_idx = len(self.skins)
        self.skins.append({
            "name": f"{m2.stem}_armature",
            # skeleton = common ancestor of all joints (the virtual Armature node)
            "skeleton": armature_idx,
            "joints": bone_node_indices,
            "inverseBindMatrices": ibm_acc,
        })
        return skin_idx

    # ── animations ───────────────────────────────────────────────────────────

    def _build_animations(self, m2: M2Parser, bone_node_indices, anim_filter):
        for seq_idx, seq in enumerate(m2.sequences):
            anim_id = seq['id']
            if anim_filter is not None and anim_id not in anim_filter:
                continue

            # Skip alias animations (point to another sequence)
            if seq['alias_next'] != seq_idx and seq['alias_next'] != 0xFFFF:
                continue

            duration_s = seq['duration'] / 1000.0
            if duration_s <= 0:
                continue

            name = ANIM_NAMES.get(anim_id, f"Anim_{anim_id}")
            if seq['variation'] > 0:
                name += f"_var{seq['variation']}"

            channels  = []
            samplers  = []

            for bone_idx, bone in enumerate(m2.bones):
                node_idx = bone_node_indices[bone_idx]

                # Translation
                # Pivot offset lives on the parent pivot node. The animated bone node
                # receives only the M2 translation offset.
                ts, vals = m2._resolve_track(bone['t_track'], seq_idx, 'vec3')
                if ts and vals:
                    s_idx = self._anim_sampler(ts, vals, 'VEC3',
                                               lambda v: wow_pos_to_gltf(*v))
                    if s_idx is not None:
                        samplers.append(s_idx)
                        channels.append({"sampler": len(samplers)-1,
                                         "target": {"node": node_idx, "path": "translation"}})

                # Rotation
                # M2 r_track quaternions are the bone's absolute local rotation.
                # Rest rotation is identity so no adjustment needed.
                ts, vals = m2._resolve_track(bone['r_track'], seq_idx, 'quat_c')
                if ts and vals:
                    s_idx = self._anim_sampler(ts, vals, 'VEC4',
                                               lambda v: wow_quat_to_gltf(*v))
                    if s_idx is not None:
                        samplers.append(s_idx)
                        channels.append({"sampler": len(samplers)-1,
                                         "target": {"node": node_idx, "path": "rotation"}})

                # Scale
                # M2 s_track values are absolute local scale (1,1,1 at rest).
                ts, vals = m2._resolve_track(bone['s_track'], seq_idx, 'vec3')
                if ts and vals:
                    s_idx = self._anim_sampler(ts, vals, 'VEC3',
                                               lambda v: wow_scale_to_gltf(*v))
                    if s_idx is not None:
                        samplers.append(s_idx)
                        channels.append({"sampler": len(samplers)-1,
                                         "target": {"node": node_idx, "path": "scale"}})

            if channels:
                self.animations.append({
                    "name": name,
                    "samplers": samplers,
                    "channels": channels,
                })
                print(f"  Anim '{name}': {len(channels)} channels")

    def _anim_sampler(self, timestamps_ms, values, acc_type, conv_fn):
        """
        Build a GLTF animation sampler dict from raw M2 timestamps (ms) and values.
        Returns the sampler dict to append to animation["samplers"], or None on error.
        """
        if not timestamps_ms or not values or len(timestamps_ms) != len(values):
            return None

        n = len(timestamps_ms)

        # Input: timestamps in seconds
        times = [t / 1000.0 for t in timestamps_ms]
        t_bytes = struct.pack(f'<{n}f', *times)
        t_min = [min(times)]
        t_max = [max(times)]
        t_acc = self._add_accessor(t_bytes, n, 5126, "SCALAR", t_min, t_max)

        # Output: converted values
        try:
            converted = [conv_fn(v) for v in values]
        except Exception:
            converted = [tuple(v) for v in values]

        flat = [x for c in converted for x in c]
        v_bytes = struct.pack(f'<{len(flat)}f', *flat)

        if acc_type == 'VEC4':
            # Normalize quaternions
            norm_flat = []
            for i in range(0, len(flat), 4):
                q = quat_norm(flat[i:i+4])
                norm_flat.extend(q)
            v_bytes = struct.pack(f'<{len(norm_flat)}f', *norm_flat)

        v_acc = self._add_accessor(v_bytes, n, 5126, acc_type)

        return {"input": t_acc, "output": v_acc, "interpolation": "LINEAR"}

    # ── GLB writer ───────────────────────────────────────────────────────────

    def _write_glb(self, gltf: dict) -> bytes:
        json_bytes = json.dumps(gltf, separators=(',', ':')).encode('utf-8')
        # Pad JSON to 4-byte boundary with spaces
        pad = (4 - len(json_bytes) % 4) % 4
        json_bytes += b' ' * pad

        bin_bytes = bytes(self.bin_data)
        # Pad binary to 4-byte boundary
        bin_pad = (4 - len(bin_bytes) % 4) % 4
        bin_bytes += b'\x00' * bin_pad

        # GLB header: magic(4) version(4) total_length(4)
        json_chunk = struct.pack('<II', len(json_bytes), 0x4E4F534A) + json_bytes
        bin_chunk  = struct.pack('<II', len(bin_bytes),  0x004E4942) + bin_bytes

        total = 12 + len(json_chunk) + len(bin_chunk)
        header = struct.pack('<III', 0x46546C67, 2, total)

        return header + json_chunk + bin_chunk


# ─────────────────────────────────────────────────────────────────────────────
# Entry point
# ─────────────────────────────────────────────────────────────────────────────

def _export_texture_layers(m2_path: Path, out_dir: Path) -> Path | None:
    """
    Export individual BLP texture layers as PNG files for runtime compositing.

    Scans the M2's directory for the following layer types and saves them to
    {out_dir}/{stem}_textures/ with a normalised naming scheme:

      skin_{color:02d}.png          – base body skin variants
      naked_torso_{color:02d}.png   – NakedTorsoSkin (bra area)
      naked_pelvis_{color:02d}.png  – NakedPelvisSkin (panties area)
      face_lower_{style:02d}_{color:02d}.png
      face_upper_{style:02d}_{color:02d}.png

    Returns the textures directory Path, or None if PIL is not available.
    """
    if not _PIL_OK:
        return None

    import re as _re

    m2_dir  = m2_path.parent
    stem    = m2_path.stem
    tex_dir = out_dir / f"{stem}_textures"
    tex_dir.mkdir(parents=True, exist_ok=True)

    stem_esc = _re.escape(stem.lower())
    # (regex_pattern, layer_name, has_style_group)
    layer_rules = [
        (_re.compile(rf'^{stem_esc}skin(\d+)_(\d+)$',            _re.I), 'skin',         False),
        (_re.compile(rf'^{stem_esc}nakedtorsoskin(\d+)_(\d+)$',  _re.I), 'naked_torso',  False),
        (_re.compile(rf'^{stem_esc}nakedpelvisskin(\d+)_(\d+)$', _re.I), 'naked_pelvis', False),
        (_re.compile(rf'^{stem_esc}facelower(\d+)_(\d+)$',       _re.I), 'face_lower',   True),
        (_re.compile(rf'^{stem_esc}faceupper(\d+)_(\d+)$',       _re.I), 'face_upper',   True),
    ]

    count = 0
    for blp_file in sorted(m2_dir.iterdir()):
        if blp_file.suffix.lower() != '.blp':
            continue
        blp_stem = blp_file.stem

        for pattern, layer_name, has_style in layer_rules:
            m = pattern.match(blp_stem)
            if not m:
                continue
            style_idx = int(m.group(1))
            color_idx = int(m.group(2))

            if has_style:
                out_name = f"{layer_name}_{style_idx:02d}_{color_idx:02d}.png"
            else:
                out_name = f"{layer_name}_{color_idx:02d}.png"

            result = _blp_to_rgba(blp_file)
            if result is None:
                break
            w, h, rgba = result
            png_bytes = _rgba_to_png_bytes(w, h, rgba)
            (tex_dir / out_name).write_bytes(png_bytes)
            count += 1
            break   # matched – next BLP file

    if count:
        print(f"Texture layers: {count} PNGs -> {tex_dir.name}/")
    return tex_dir


def convert(m2_path: str, output_dir: str = None, anim_filter=None):
    m2_path = Path(m2_path)
    if not m2_path.exists():
        raise FileNotFoundError(f"M2 file not found: {m2_path}")

    print(f"\n=== Converting {m2_path.name} ===")

    # Find skin file: <stem>00.skin
    skin_path = m2_path.parent / (m2_path.stem + "00.skin")
    if not skin_path.exists():
        # Try uppercase
        for f in m2_path.parent.iterdir():
            if f.suffix.lower() == '.skin':
                skin_path = f
                break
    if not skin_path.exists():
        raise FileNotFoundError(f"Skin file not found near {m2_path}")
    print(f"Skin: {skin_path.name}")

    m2   = M2Parser(str(m2_path))
    skin = SkinParser(str(skin_path))

    builder = GltfBuilder()
    glb = builder.build(m2, skin, anim_filter=anim_filter)

    out_dir = Path(output_dir) if output_dir else m2_path.parent
    out_dir.mkdir(parents=True, exist_ok=True)
    out_path = out_dir / (m2_path.stem + ".glb")

    with open(out_path, 'wb') as f:
        f.write(glb)

    print(f"Wrote {len(glb):,} bytes -> {out_path}")

    # Export individual texture layers for runtime compositing
    _export_texture_layers(m2_path, out_dir)

    return str(out_path)


if __name__ == '__main__':
    import argparse

    ap = argparse.ArgumentParser(
        description="WoW 3.3.5 M2 -> GLTF 2.0 GLB converter",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    ap.add_argument("input", nargs="?",
                    help="Path to a single .M2 file (omit when using --all)")
    ap.add_argument("output_dir", nargs="?",
                    help="Output directory for single-file mode")
    ap.add_argument("--all", metavar="SEARCH_DIR",
                    help="Recursively convert all .M2 files under SEARCH_DIR")
    ap.add_argument("--out", metavar="OUT_ROOT", default=None,
                    help="Root output dir for --all mode. "
                         "Mirror the subpath relative to SEARCH_DIR. "
                         "Defaults to <cwd>/src/resources")
    args = ap.parse_args()

    if args.all:
        search_root = Path(args.all).resolve()
        out_root = Path(args.out).resolve() if args.out else Path("src/resources")
        m2_files = sorted(search_root.rglob("*.M2"))
        if not m2_files:
            print(f"No .M2 files found under {search_root}")
            sys.exit(1)
        print(f"Found {len(m2_files)} M2 file(s) under {search_root}")
        ok = err = 0
        for m2 in m2_files:
            rel = m2.relative_to(search_root)
            out_dir = out_root / rel.parent
            out_dir.mkdir(parents=True, exist_ok=True)
            try:
                convert(str(m2), str(out_dir))
                ok += 1
            except Exception as e:
                print(f"  ERROR {m2.name}: {e}")
                err += 1
        print(f"\nDone: {ok} converted, {err} failed")
    elif args.input:
        convert(args.input, args.output_dir)
    else:
        ap.print_help()
        sys.exit(1)
