#include "m2_loader.h"

#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <godot_cpp/variant/packed_string_array.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/packed_vector2_array.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/packed_float32_array.hpp>
#include <godot_cpp/variant/packed_vector4_array.hpp>
#include <godot_cpp/variant/color.hpp>

#include <fstream>
#include <cstring>
#include <cstdio>
#include <cmath>
#include <algorithm>
#include <cstdint>
#include <cctype>

using namespace godot;

// ─────────────────────────────────────────────────────────────────────────────
// Raw structures (WoW 3.3.5a, little-endian, packed)
// ─────────────────────────────────────────────────────────────────────────────
#pragma pack(push, 1)

// M2 file header — starts at offset 0, no chunk wrapper in 3.3.5a
struct M2Header {
    uint32_t magic;              // 'MD20' LE = 0x3032444D
    uint32_t version;
    uint32_t nName;
    uint32_t ofsName;
    uint32_t flags;
    uint32_t nGlobalLoops;
    uint32_t ofsGlobalLoops;
    uint32_t nAnimations;
    uint32_t ofsAnimations;
    uint32_t nAnimLookup;
    uint32_t ofsAnimLookup;
    uint32_t nBones;
    uint32_t ofsBones;
    uint32_t nKeyBoneLookup;     // unused for static render
    uint32_t ofsKeyBoneLookup;
    uint32_t nVertices;          // offset 60
    uint32_t ofsVertices;        // offset 64
    uint32_t nViews;             // offset 68 — number of .skin files
    uint32_t nColors;
    uint32_t ofsColors;
    uint32_t nTextures;          // offset 80
    uint32_t ofsTextures;        // offset 84
    uint32_t nTexWeights;        // unused
    uint32_t ofsTexWeights;
    uint32_t nTexTransforms;     // unused
    uint32_t ofsTexTransforms;
    uint32_t nReplTexLookup;     // unused
    uint32_t ofsReplTexLookup;
    uint32_t nMaterials;         // offset 112
    uint32_t ofsMaterials;       // offset 116
    uint32_t nBoneCombos;        // unused
    uint32_t ofsBoneCombos;
    uint32_t nTextureCombos;     // offset 128
    uint32_t ofsTextureCombos;   // offset 132
    uint32_t nTextureCoordCombos;
    uint32_t ofsTextureCoordCombos;
    uint32_t nTextureWeightCombos;
    uint32_t ofsTextureWeightCombos;
    uint32_t nTextureTransformCombos;
    uint32_t ofsTextureTransformCombos;
    float    bounds[7];
    float    collisionBounds[7];
    uint32_t nCollisionIndices;
    uint32_t ofsCollisionIndices;
    uint32_t nCollisionPositions;
    uint32_t ofsCollisionPositions;
    uint32_t nCollisionFaceNormals;
    uint32_t ofsCollisionFaceNormals;
    uint32_t nAttachments;
    uint32_t ofsAttachments;
    uint32_t nAttachmentLookup;
    uint32_t ofsAttachmentLookup;
    uint32_t nEvents;
    uint32_t ofsEvents;
    uint32_t nLights;
    uint32_t ofsLights;
    uint32_t nCameras;
    uint32_t ofsCameras;
    uint32_t nCameraLookup;
    uint32_t ofsCameraLookup;
    uint32_t nRibbonEmitters;
    uint32_t ofsRibbonEmitters;
    uint32_t nParticleEmitters;
    uint32_t ofsParticleEmitters;
    uint32_t nTextureCombinerCombos;
    uint32_t ofsTextureCombinerCombos;
};
// Total: 34 × 4 = 136 bytes

struct M2Vertex {
    float   pos[3];
    uint8_t boneWeights[4];
    uint8_t boneIndices[4];
    float   normal[3];
    float   texCoords[2];
    float   texCoords2[2];
};
// Total: 48 bytes

struct M2Texture {
    uint32_t type;        // 0 = hardcoded filename
    uint32_t flags;
    uint32_t nFilename;
    uint32_t ofsFilename;
};

struct M2Material {
    uint16_t flags;
    uint16_t blendingMode;  // 0=opaque 1=alpha_key 2=alpha_blend
};

struct M2ArrayRef {
    uint32_t count;
    uint32_t offset;
};

struct M2Track {
    uint16_t interpolationType;
    uint16_t globalSequence;
    M2ArrayRef timestamps;
    M2ArrayRef values;
};

struct M2Color {
    M2Track colorTrack;
    M2Track alphaTrack;
};

struct M2TextureTransform {
    M2Track translationTrack;
    M2Track rotationTrack;
    M2Track scaleTrack;
};

struct M2TextureWeight {
    M2Track weightTrack;
};

struct M2CompBone {
    uint32_t boneId;
    uint32_t flags;
    uint16_t parentIndex;
    uint16_t submeshId;
    uint32_t unknown;
    M2Track  translation;
    M2Track  rotation;
    M2Track  scale;
    float    pivot[3];
};

struct M2Sequence {
    uint16_t id;
    uint16_t variationIndex;
    uint32_t duration;
    float    moveSpeed;
    uint32_t flags;
    uint32_t frequency;
    uint32_t replayMin;
    uint32_t replayMax;
    uint32_t blendTime;
    float    minBounds[3];
    float    maxBounds[3];
    float    boundsRadius;
    uint16_t nextAnimation;
    uint16_t aliasNext;
};

struct M2Float3 {
    float v[3];
};

struct M2CompQuat {
    uint16_t v[4];
};

struct SkinHeader {
    uint32_t magic;          // 'SKIN' LE = 0x4E494B53
    uint32_t nIndices;
    uint32_t ofsIndices;
    uint32_t nTriangles;
    uint32_t ofsTriangles;
    uint32_t nProperties;
    uint32_t ofsProperties;
    uint32_t nSubMeshes;
    uint32_t ofsSubMeshes;
    uint32_t nTextureUnits;
    uint32_t ofsTextureUnits;
    uint32_t LOD;
};
// Total: 48 bytes

struct SkinSubMesh {
    uint16_t submeshID;
    uint16_t level;
    uint16_t vertexStart;
    uint16_t vertexCount;
    uint16_t triangleStart;
    uint16_t triangleCount;
    uint16_t boneCount;
    uint16_t boneStart;
    uint16_t boneInfluences;
    uint16_t centerBoneIndex;
    float    centerPosition[3];
    float    sortCenterPosition[3];
    float    sortRadius;
};
// Total: 10×2 + 7×4 = 48 bytes

struct SkinTextureUnit {
    uint8_t  flags;
    uint8_t  priority;
    uint16_t shaderID;
    uint16_t skinSectionIndex;
    uint16_t flags2;
    uint16_t colorIndex;
    uint16_t materialIndex;
    uint16_t materialLayer;
    uint16_t textureCount;
    uint16_t textureComboIndex;
    uint16_t textureCoordComboIndex;
    uint16_t textureWeightComboIndex;
    uint16_t textureTransformComboIndex;
};
// Total: 2 + 11×2 = 24 bytes

#pragma pack(pop)

static constexpr uint32_t MAGIC_MD20 = 0x3032444D; // 'MD20' LE
static constexpr uint32_t MAGIC_SKIN = 0x4E494B53; // 'SKIN' LE
static constexpr uint32_t M2_BONE_FLAG_SPHERICAL_BILLBOARD = 0x8;
static constexpr uint32_t M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_X = 0x10;
static constexpr uint32_t M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_Y = 0x20;
static constexpr uint32_t M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_Z = 0x40;
static constexpr uint32_t M2_BONE_BILLBOARD_MASK =
    M2_BONE_FLAG_SPHERICAL_BILLBOARD
    | M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_X
    | M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_Y
    | M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_Z;

// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
std::vector<uint8_t> M2Loader::read_file(const std::string &path) {
    std::ifstream f(path, std::ios::binary | std::ios::ate);
    if (!f) return {};
    auto sz = f.tellg();
    f.seekg(0);
    std::vector<uint8_t> buf(sz);
    f.read(reinterpret_cast<char *>(buf.data()), sz);
    return buf;
}

std::string M2Loader::to_std(const String &s) {
    return std::string(s.utf8().get_data());
}

String M2Loader::to_godot(const std::string &s) {
    return String(s.c_str());
}

// Safe bounds-checked array accessor into a buffer
template<typename T>
static const T *safe_array(const std::vector<uint8_t> &buf, uint32_t ofs, uint32_t count) {
    if (count == 0) return nullptr;
    uint64_t end = (uint64_t)ofs + (uint64_t)count * sizeof(T);
    if (end > buf.size()) return nullptr;
    return reinterpret_cast<const T *>(buf.data() + ofs);
}

static Vector3 wow_vec3_to_godot(const float v[3]) {
    return Vector3(v[0], v[2], -v[1]);
}

static Vector3 wow_vec3_to_godot(float x, float y, float z) {
    return Vector3(x, z, -y);
}

static int billboard_mode_from_bone_flags(uint32_t flags) {
    if (flags & M2_BONE_FLAG_SPHERICAL_BILLBOARD) return 1;
    if (flags & M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_X) return 2;
    if (flags & M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_Y) return 3;
    if (flags & M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_Z) return 4;
    return 0;
}

static bool resolve_vertex_billboard(
        const M2Vertex *verts,
        uint32_t vertex_count,
        uint16_t vertex_lookup,
        uint16_t global_vertex_index,
        const uint8_t *skin_bones,
        uint32_t skin_property_count,
        const uint16_t *bone_combo_arr,
        uint32_t bone_combo_count,
        uint16_t bone_start,
        const M2CompBone *bones,
        uint32_t bone_count,
        Vector3 &pivot_out,
        int &mode_out,
        uint32_t &flags_out) {
    if (!verts || !bones || global_vertex_index >= vertex_count) {
        return false;
    }

    const M2Vertex &vertex = verts[global_vertex_index];
    bool all_weights_zero = true;
    for (uint32_t i = 0; i < 4; ++i) {
        if (vertex.boneWeights[i] > 0) {
            all_weights_zero = false;
            break;
        }
    }

    int best_bone = -1;
    uint32_t best_flags = 0;
    uint8_t best_weight = 0;
    for (uint32_t i = 0; i < 4; ++i) {
        uint8_t weight = vertex.boneWeights[i];
        if (!all_weights_zero && weight == 0) {
            continue;
        }

        int local_bone = (int)vertex.boneIndices[i];
        if (skin_bones && vertex_lookup < skin_property_count) {
            local_bone = (int)skin_bones[(uint32_t)vertex_lookup * 4 + i];
        }

        int global_bone = local_bone;
        uint32_t lookup_index = (uint32_t)bone_start + (uint32_t)std::max(local_bone, 0);
        if (bone_combo_arr && lookup_index < bone_combo_count) {
            global_bone = (int)bone_combo_arr[lookup_index];
        }
        if (global_bone < 0 || (uint32_t)global_bone >= bone_count) {
            continue;
        }

        uint32_t flags = bones[global_bone].flags;
        if ((flags & M2_BONE_BILLBOARD_MASK) == 0) {
            continue;
        }
        if (best_bone < 0 || weight > best_weight) {
            best_bone = global_bone;
            best_flags = flags;
            best_weight = weight;
        }
    }

    if (best_bone < 0) {
        return false;
    }

    mode_out = billboard_mode_from_bone_flags(best_flags);
    if (mode_out <= 0) {
        return false;
    }
    pivot_out = wow_vec3_to_godot(bones[best_bone].pivot);
    flags_out = best_flags;
    return true;
}

static Vector4 normalize_quat(float x, float y, float z, float w) {
    float len = std::sqrt(x * x + y * y + z * z + w * w);
    if (len <= 0.000001f) {
        return Vector4(0.0f, 0.0f, 0.0f, 1.0f);
    }
    float inv = 1.0f / len;
    return Vector4(x * inv, y * inv, z * inv, w * inv);
}

static Vector4 wow_quat_to_godot(float x, float y, float z, float w) {
    // WoW model space is converted to Godot with Z/Y axis swap and Y handedness flip.
    return normalize_quat(x, z, -y, w);
}

static Vector4 decode_comp_quat(const uint16_t q[4]) {
    float x = ((float)q[0] - 32768.0f) / 32767.0f;
    float y = ((float)q[1] - 32768.0f) / 32767.0f;
    float z = ((float)q[2] - 32768.0f) / 32767.0f;
    float w = ((float)q[3] - 32768.0f) / 32767.0f;
    return wow_quat_to_godot(x, y, z, w);
}

static Vector4 decode_comp_quat_raw(const uint16_t q[4]) {
    float x = ((float)q[0] - 32768.0f) / 32767.0f;
    float y = ((float)q[1] - 32768.0f) / 32767.0f;
    float z = ((float)q[2] - 32768.0f) / 32767.0f;
    float w = ((float)q[3] - 32768.0f) / 32767.0f;
    return normalize_quat(x, y, z, w);
}

static Vector4 texture_quat_to_uv_rotation(const Vector4 &q) {
    float x = q.x;
    float y = q.y;
    float z = q.z;
    float w = q.w;
    return Vector4(
        1.0f - 2.0f * (y * y + z * z),
        2.0f * (x * y - z * w),
        2.0f * (x * y + z * w),
        1.0f - 2.0f * (x * x + z * z));
}

static float texture_quat_z_angle(const Vector4 &q) {
    float x = q.x;
    float y = q.y;
    float z = q.z;
    float w = q.w;
    return std::atan2(2.0f * (w * z + x * y), 1.0f - 2.0f * (y * y + z * z));
}

template<typename T>
static const T *track_sequence_array(const std::vector<uint8_t> &buf, const M2ArrayRef &outer, uint32_t sequence_index, uint32_t &count_out) {
    count_out = 0;
    if (sequence_index >= outer.count) return nullptr;
    const M2ArrayRef *inner_arrays = safe_array<M2ArrayRef>(buf, outer.offset, outer.count);
    if (!inner_arrays) return nullptr;
    const M2ArrayRef &inner = inner_arrays[sequence_index];
    const T *items = safe_array<T>(buf, inner.offset, inner.count);
    if (!items) return nullptr;
    count_out = inner.count;
    return items;
}

static uint32_t track_sequence_key_count(const std::vector<uint8_t> &buf, const M2Track &track, uint32_t sequence_index) {
    if (sequence_index >= track.values.count) return 0;
    const M2ArrayRef *inner_arrays = safe_array<M2ArrayRef>(buf, track.values.offset, track.values.count);
    if (!inner_arrays) return 0;
    return inner_arrays[sequence_index].count;
}

template<typename T>
static const T *first_track_value(const std::vector<uint8_t> &buf, const M2Track &track) {
    for (uint32_t i = 0; i < track.values.count; ++i) {
        uint32_t value_count = 0;
        const T *values = track_sequence_array<T>(buf, track.values, i, value_count);
        if (values && value_count > 0) {
            return values;
        }
    }
    return nullptr;
}

static float fixed16_to_float(int16_t value) {
    return std::clamp((float)value / 32767.0f, 0.0f, 1.0f);
}

static Vector2 first_vec3_track_xy_speed(const std::vector<uint8_t> &buf, const M2Track &track) {
    for (uint32_t i = 0; i < track.values.count && i < track.timestamps.count; ++i) {
        uint32_t time_count = 0;
        uint32_t value_count = 0;
        const uint32_t *times = track_sequence_array<uint32_t>(buf, track.timestamps, i, time_count);
        const M2Float3 *values = track_sequence_array<M2Float3>(buf, track.values, i, value_count);
        uint32_t key_count = std::min(time_count, value_count);
        if (!times || !values || key_count < 2) {
            continue;
        }
        uint32_t first = 0;
        uint32_t last = key_count - 1;
        float duration = (float)(times[last] - times[first]) / 1000.0f;
        if (duration <= 0.0001f) {
            continue;
        }
        return Vector2(
            (values[last].v[0] - values[first].v[0]) / duration,
            (values[last].v[1] - values[first].v[1]) / duration);
    }
    return Vector2(0.0f, 0.0f);
}

static float angle_delta(float from, float to) {
    float delta = to - from;
    const float pi = 3.14159265358979323846f;
    while (delta > pi) delta -= pi * 2.0f;
    while (delta < -pi) delta += pi * 2.0f;
    return delta;
}

static float first_quat_track_z_rotation_speed(const std::vector<uint8_t> &buf, const M2Track &track) {
    for (uint32_t i = 0; i < track.values.count && i < track.timestamps.count; ++i) {
        uint32_t time_count = 0;
        uint32_t value_count = 0;
        const uint32_t *times = track_sequence_array<uint32_t>(buf, track.timestamps, i, time_count);
        const M2CompQuat *values = track_sequence_array<M2CompQuat>(buf, track.values, i, value_count);
        uint32_t key_count = std::min(time_count, value_count);
        if (!times || !values || key_count < 2) {
            continue;
        }
        uint32_t first = 0;
        uint32_t last = key_count - 1;
        float duration = (float)(times[last] - times[first]) / 1000.0f;
        if (duration <= 0.0001f) {
            continue;
        }
        float first_angle = texture_quat_z_angle(decode_comp_quat_raw(values[first].v));
        float last_angle = texture_quat_z_angle(decode_comp_quat_raw(values[last].v));
        return angle_delta(first_angle, last_angle) / duration;
    }
    return 0.0f;
}

static uint32_t sequence_activity_score(const std::vector<uint8_t> &buf, const M2CompBone *bones, uint32_t bone_count, uint32_t sequence_index) {
    if (!bones) return 0;
    uint32_t score = 0;
    for (uint32_t i = 0; i < bone_count; ++i) {
        uint32_t t = track_sequence_key_count(buf, bones[i].translation, sequence_index);
        uint32_t r = track_sequence_key_count(buf, bones[i].rotation, sequence_index);
        uint32_t s = track_sequence_key_count(buf, bones[i].scale, sequence_index);
        if (t > 1) score += t;
        if (r > 1) score += r;
        if (s > 1) score += s;
    }
    return score;
}

static uint32_t choose_animated_sequence(const std::vector<uint8_t> &buf, const M2Sequence *seqs, uint32_t seq_count, const M2CompBone *bones, uint32_t bone_count, bool prefer_calm_stand) {
    if (!seqs || seq_count == 0) return 0;

    uint32_t best_stand = UINT32_MAX;
    uint32_t best_stand_score = 0;
    uint32_t calm_stand = UINT32_MAX;
    uint32_t calm_stand_score = UINT32_MAX;
    uint32_t best_any = UINT32_MAX;
    uint32_t best_any_score = 0;

    for (uint32_t i = 0; i < seq_count; ++i) {
        if (seqs[i].duration == 0) continue;
        uint32_t score = sequence_activity_score(buf, bones, bone_count, i);
        if (seqs[i].id == 0 && score > best_stand_score) {
            best_stand = i;
            best_stand_score = score;
        }
        if (seqs[i].id == 0 && score > 0 && score < calm_stand_score) {
            calm_stand = i;
            calm_stand_score = score;
        }
        if (score > best_any_score) {
            best_any = i;
            best_any_score = score;
        }
    }

    if (prefer_calm_stand && calm_stand != UINT32_MAX) return calm_stand;
    if (best_stand != UINT32_MAX) return best_stand;
    if (best_any != UINT32_MAX) return best_any;

    for (uint32_t i = 0; i < seq_count; ++i) {
        if (seqs[i].id == 0 && seqs[i].duration > 0) return i;
    }
    for (uint32_t i = 0; i < seq_count; ++i) {
        if (seqs[i].duration > 0) return i;
    }
    return 0;
}

static Dictionary make_vec3_track(const std::vector<uint8_t> &buf, const M2Track &track, uint32_t sequence_index, bool scale_track) {
    Dictionary result;
    uint32_t time_count = 0;
    uint32_t value_count = 0;
    const uint32_t *times = track_sequence_array<uint32_t>(buf, track.timestamps, sequence_index, time_count);
    const M2Float3 *values = track_sequence_array<M2Float3>(buf, track.values, sequence_index, value_count);
    if (!times || !values || time_count == 0 || value_count == 0) {
        return result;
    }
    uint32_t key_count = std::min(time_count, value_count);
    PackedFloat32Array out_times;
    PackedVector3Array out_values;
    out_times.resize(key_count);
    out_values.resize(key_count);
    for (uint32_t i = 0; i < key_count; ++i) {
        out_times[i] = (float)times[i] / 1000.0f;
        if (scale_track) {
            out_values[i] = Vector3(values[i].v[0], values[i].v[1], values[i].v[2]);
        } else {
            out_values[i] = wow_vec3_to_godot(values[i].v[0], values[i].v[1], values[i].v[2]);
        }
    }
    result["times"] = out_times;
    result["values"] = out_values;
    return result;
}

static Dictionary make_quat_track(const std::vector<uint8_t> &buf, const M2Track &track, uint32_t sequence_index) {
    Dictionary result;
    uint32_t time_count = 0;
    uint32_t value_count = 0;
    const uint32_t *times = track_sequence_array<uint32_t>(buf, track.timestamps, sequence_index, time_count);
    const M2CompQuat *values = track_sequence_array<M2CompQuat>(buf, track.values, sequence_index, value_count);
    if (!times || !values || time_count == 0 || value_count == 0) {
        return result;
    }
    uint32_t key_count = std::min(time_count, value_count);
    PackedFloat32Array out_times;
    PackedVector4Array out_values;
    out_times.resize(key_count);
    out_values.resize(key_count);
    for (uint32_t i = 0; i < key_count; ++i) {
        out_times[i] = (float)times[i] / 1000.0f;
        out_values[i] = decode_comp_quat(values[i].v);
    }
    result["times"] = out_times;
    result["values"] = out_values;
    return result;
}

// ─────────────────────────────────────────────────────────────────────────────
// Core parser
// ─────────────────────────────────────────────────────────────────────────────
Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string &path, bool include_animation) {
    if (buf.size() < sizeof(M2Header)) return Dictionary();

    const auto &hdr = *reinterpret_cast<const M2Header *>(buf.data());
    if (hdr.magic != MAGIC_MD20) {
        UtilityFunctions::push_error("M2Loader: unexpected magic, expected MD20");
        return Dictionary();
    }

    // ── Vertices ─────────────────────────────────────────────────────────────
    PackedVector3Array vertices, normals;
    PackedVector2Array uvs, uvs2;

    const auto *verts = safe_array<M2Vertex>(buf, hdr.ofsVertices, hdr.nVertices);
    if (verts && hdr.nVertices > 0) {
        vertices.resize(hdr.nVertices);
        normals.resize(hdr.nVertices);
        uvs.resize(hdr.nVertices);
        uvs2.resize(hdr.nVertices);
        for (uint32_t i = 0; i < hdr.nVertices; ++i) {
            const auto &v = verts[i];
            // WoW model space (X right, Y forward, Z up) → Godot (X right, Y up, Z back)
            vertices[i] = Vector3( v.pos[0],  v.pos[2], -v.pos[1]);
            normals[i]  = Vector3( v.normal[0],  v.normal[2], -v.normal[1]);
            // WoW BLP textures and Godot 4 sampling both use V=0 at top
            // (D3D/Vulkan convention) — pass UVs through unchanged. Same as
            // wmo_loader's MOTV handling.
            uvs[i] = Vector2(v.texCoords[0], v.texCoords[1]);
            uvs2[i] = Vector2(v.texCoords2[0], v.texCoords2[1]);
        }
    }

    // ── Textures ─────────────────────────────────────────────────────────────
    PackedStringArray textures;
    PackedInt32Array texture_types;
    PackedInt32Array texture_flags;
    const auto *tex_arr = safe_array<M2Texture>(buf, hdr.ofsTextures, hdr.nTextures);
    if (tex_arr) {
        for (uint32_t i = 0; i < hdr.nTextures; ++i) {
            const auto &t = tex_arr[i];
            std::string fname;
            if (t.type == 0 && t.nFilename > 0) {
                uint64_t end = (uint64_t)t.ofsFilename + t.nFilename;
                if (end <= buf.size()) {
                    fname = std::string(
                        reinterpret_cast<const char *>(buf.data() + t.ofsFilename),
                        t.nFilename);
                    // trim null terminators
                    while (!fname.empty() && fname.back() == '\0')
                        fname.pop_back();
                    for (auto &c : fname) if (c == '\\') c = '/';
                }
            }
            textures.push_back(to_godot(fname));
            texture_types.push_back((int)t.type);
            texture_flags.push_back((int)t.flags);
        }
    }

    // ── Materials ────────────────────────────────────────────────────────────
    Array materials;
    const auto *mat_arr = safe_array<M2Material>(buf, hdr.ofsMaterials, hdr.nMaterials);
    if (mat_arr) {
        for (uint32_t i = 0; i < hdr.nMaterials; ++i) {
            Dictionary m;
            m["flags"]      = (int)mat_arr[i].flags;
            m["blend_mode"] = (int)mat_arr[i].blendingMode;
            materials.push_back(m);
        }
    }

    // ── Texture combos ───────────────────────────────────────────────────────
    PackedInt32Array texture_combos;
    const auto *tc_arr = safe_array<uint16_t>(buf, hdr.ofsTextureCombos, hdr.nTextureCombos);
    if (tc_arr) {
        for (uint32_t i = 0; i < hdr.nTextureCombos; ++i)
            texture_combos.push_back((int)tc_arr[i]);
    }

    PackedInt32Array texture_coord_combos;
    const auto *tcc_arr = safe_array<uint16_t>(buf, hdr.ofsTextureCoordCombos, hdr.nTextureCoordCombos);
    if (tcc_arr) {
        for (uint32_t i = 0; i < hdr.nTextureCoordCombos; ++i)
            texture_coord_combos.push_back((int)tcc_arr[i]);
    }

    PackedInt32Array texture_weight_combos;
    const auto *twc_arr = safe_array<uint16_t>(buf, hdr.ofsTextureWeightCombos, hdr.nTextureWeightCombos);
    if (twc_arr) {
        for (uint32_t i = 0; i < hdr.nTextureWeightCombos; ++i)
            texture_weight_combos.push_back((int)twc_arr[i]);
    }

    PackedInt32Array texture_transform_combos;
    const auto *ttc_arr = safe_array<uint16_t>(buf, hdr.ofsTextureTransformCombos, hdr.nTextureTransformCombos);
    if (ttc_arr) {
        for (uint32_t i = 0; i < hdr.nTextureTransformCombos; ++i)
            texture_transform_combos.push_back((int)ttc_arr[i]);
    }

    PackedInt32Array texture_combiner_combos;
    const auto *tcomb_arr = safe_array<uint16_t>(buf, hdr.ofsTextureCombinerCombos, hdr.nTextureCombinerCombos);
    if (tcomb_arr) {
        for (uint32_t i = 0; i < hdr.nTextureCombinerCombos; ++i)
            texture_combiner_combos.push_back((int)tcomb_arr[i]);
    }

    // ── Skin file ────────────────────────────────────────────────────────────
    Array m2_colors;
    const auto *color_arr = safe_array<M2Color>(buf, hdr.ofsColors, hdr.nColors);
    if (color_arr) {
        for (uint32_t i = 0; i < hdr.nColors; ++i) {
            Color color(1.0f, 1.0f, 1.0f, 1.0f);
            const M2Float3 *rgb = first_track_value<M2Float3>(buf, color_arr[i].colorTrack);
            if (rgb) {
                color.r = rgb->v[0];
                color.g = rgb->v[1];
                color.b = rgb->v[2];
            }
            const int16_t *alpha = first_track_value<int16_t>(buf, color_arr[i].alphaTrack);
            if (alpha) {
                color.a = fixed16_to_float(*alpha);
            }
            Dictionary c;
            c["color"] = color;
            m2_colors.push_back(c);
        }
    }

    PackedFloat32Array texture_weights;
    const auto *weight_arr = safe_array<M2TextureWeight>(buf, hdr.ofsTexWeights, hdr.nTexWeights);
    if (weight_arr) {
        texture_weights.resize(hdr.nTexWeights);
        for (uint32_t i = 0; i < hdr.nTexWeights; ++i) {
            float weight = 1.0f;
            const int16_t *value = first_track_value<int16_t>(buf, weight_arr[i].weightTrack);
            if (value) {
                weight = fixed16_to_float(*value);
            }
            texture_weights[i] = weight;
        }
    }

    Array texture_transforms;
    const auto *transform_arr = safe_array<M2TextureTransform>(buf, hdr.ofsTexTransforms, hdr.nTexTransforms);
    if (transform_arr) {
        for (uint32_t i = 0; i < hdr.nTexTransforms; ++i) {
            Vector2 translation(0.0f, 0.0f);
            Vector2 scale(1.0f, 1.0f);
            Vector4 rotation(1.0f, 0.0f, 0.0f, 1.0f);
            const M2Float3 *trans = first_track_value<M2Float3>(buf, transform_arr[i].translationTrack);
            if (trans) {
                translation = Vector2(trans->v[0], trans->v[1]);
            }
            const M2CompQuat *rot = first_track_value<M2CompQuat>(buf, transform_arr[i].rotationTrack);
            if (rot) {
                rotation = texture_quat_to_uv_rotation(decode_comp_quat_raw(rot->v));
            }
            const M2Float3 *scl = first_track_value<M2Float3>(buf, transform_arr[i].scaleTrack);
            if (scl) {
                scale = Vector2(scl->v[0], scl->v[1]);
            }
            Dictionary t;
            t["translation"] = translation;
            t["rotation"] = rotation;
            t["scale"] = scale;
            t["translation_speed"] = first_vec3_track_xy_speed(buf, transform_arr[i].translationTrack);
            t["rotation_speed"] = first_quat_track_z_rotation_speed(buf, transform_arr[i].rotationTrack);
            texture_transforms.push_back(t);
        }
    }

    // Find <basename>00.skin in the same directory as the .m2
    std::string skin_path = path;
    {
        auto dot = skin_path.rfind('.');
        if (dot != std::string::npos)
            skin_path = skin_path.substr(0, dot);
        skin_path += "00.skin";
    }

    auto skin_buf = read_file(skin_path);

    PackedInt32Array indices;
    PackedInt32Array bone_lookup_table;
    Array batches;
    Array animated_surfaces;
    const M2CompBone *bones = safe_array<M2CompBone>(buf, hdr.ofsBones, hdr.nBones);
    const uint16_t *bone_combo_arr = safe_array<uint16_t>(buf, hdr.ofsBoneCombos, hdr.nBoneCombos);
    if (bone_combo_arr) {
        bone_lookup_table.resize(hdr.nBoneCombos);
        for (uint32_t i = 0; i < hdr.nBoneCombos; ++i) {
            bone_lookup_table[i] = (int)bone_combo_arr[i];
        }
    }

    if (skin_buf.size() >= sizeof(SkinHeader)) {
        const auto &skin = *reinterpret_cast<const SkinHeader *>(skin_buf.data());

        if (skin.magic == MAGIC_SKIN) {
            const uint16_t *skin_idx = safe_array<uint16_t>(skin_buf, skin.ofsIndices,   skin.nIndices);
            const uint16_t *skin_tri = safe_array<uint16_t>(skin_buf, skin.ofsTriangles, skin.nTriangles);
            const uint8_t *skin_bones = safe_array<uint8_t>(skin_buf, skin.ofsProperties, skin.nProperties * 4);
            const SkinSubMesh    *sms = safe_array<SkinSubMesh>   (skin_buf, skin.ofsSubMeshes,   skin.nSubMeshes);
            const SkinTextureUnit *tu  = safe_array<SkinTextureUnit>(skin_buf, skin.ofsTextureUnits, skin.nTextureUnits);

            if (skin_idx && skin_tri && sms && tu) {
                for (uint32_t u = 0; u < skin.nTextureUnits; ++u) {
                    uint32_t sm_idx = tu[u].skinSectionIndex;
                    if (sm_idx >= skin.nSubMeshes) continue;
                    const SkinSubMesh &sm = sms[sm_idx];

                    // triangleStart may be extended by level in large models
                    uint32_t tri_start = (uint32_t)sm.triangleStart + ((uint32_t)sm.level << 16);
                    uint32_t tri_count  = sm.triangleCount;
                    if (tri_start + tri_count > skin.nTriangles) continue;

                    int idx_start = (int)indices.size();

                    // Each group of 3 triangle-array entries forms one triangle.
                    // Remap: final_vertex = skin_idx[skin_tri[t]]
                    // Reverse winding (WoW CW → Godot CCW).
                    for (uint32_t t = 0; t + 2 < tri_count; t += 3) {
                        uint16_t a = skin_tri[tri_start + t];
                        uint16_t b = skin_tri[tri_start + t + 1];
                        uint16_t c = skin_tri[tri_start + t + 2];
                        if (a >= skin.nIndices || b >= skin.nIndices || c >= skin.nIndices)
                            continue;
                        indices.push_back((int)skin_idx[a]);
                        indices.push_back((int)skin_idx[c]); // reversed
                        indices.push_back((int)skin_idx[b]);
                    }

                    int idx_count = (int)indices.size() - idx_start;
                    if (idx_count <= 0) continue;

                    PackedFloat32Array billboard_data;
                    uint32_t billboard_vertex_count = 0;
                    uint32_t billboard_flags_seen = 0;
                    if (verts && bones) {
                        for (uint32_t t = 0; t + 2 < tri_count; t += 3) {
                            uint16_t tri_local[3] = {
                                skin_tri[tri_start + t],
                                skin_tri[tri_start + t + 2],
                                skin_tri[tri_start + t + 1],
                            };
                            for (uint32_t corner = 0; corner < 3; ++corner) {
                                uint16_t vertex_lookup = tri_local[corner];
                                if (vertex_lookup >= skin.nIndices) {
                                    continue;
                                }
                                uint16_t global_vertex_index = skin_idx[vertex_lookup];
                                Vector3 pivot;
                                int mode = 0;
                                uint32_t bone_flags = 0;
                                if (!resolve_vertex_billboard(
                                            verts,
                                            hdr.nVertices,
                                            vertex_lookup,
                                            global_vertex_index,
                                            skin_bones,
                                            skin.nProperties,
                                            bone_combo_arr,
                                            hdr.nBoneCombos,
                                            sm.boneStart,
                                            bones,
                                            hdr.nBones,
                                            pivot,
                                            mode,
                                            bone_flags)) {
                                    continue;
                                }
                                if (billboard_data.is_empty()) {
                                    billboard_data.resize(hdr.nVertices * 4);
                                }
                                uint32_t dst = (uint32_t)global_vertex_index * 4;
                                if (dst + 3 >= (uint32_t)billboard_data.size()) {
                                    continue;
                                }
                                billboard_data[dst] = pivot.x;
                                billboard_data[dst + 1] = pivot.y;
                                billboard_data[dst + 2] = pivot.z;
                                billboard_data[dst + 3] = (float)mode;
                                billboard_vertex_count++;
                                billboard_flags_seen |= (bone_flags & M2_BONE_BILLBOARD_MASK);
                            }
                        }
                    }

                    Dictionary batch;
                    batch["index_start"]         = idx_start;
                    batch["index_count"]          = idx_count;
                    batch["material_id"]          = (int)tu[u].materialIndex;
                    batch["texture_combo_index"]  = (int)tu[u].textureComboIndex;
                    batch["texture_count"]        = (int)tu[u].textureCount;
                    batch["shader_id"]            = (int)tu[u].shaderID;
                    batch["texture_coord_combo_index"] = (int)tu[u].textureCoordComboIndex;
                    batch["texture_weight_combo_index"] = (int)tu[u].textureWeightComboIndex;
                    batch["texture_transform_combo_index"] = (int)tu[u].textureTransformComboIndex;
                    batch["material_layer"]       = (int)tu[u].materialLayer;
                    batch["color_index"]          = (int)tu[u].colorIndex;
                    batch["priority_plane"]       = (int)tu[u].priority;
                    batch["skin_flags"]           = (int)tu[u].flags;
                    batch["skin_flags2"]          = (int)tu[u].flags2;
                    batch["bone_count"]           = (int)sm.boneCount;
                    batch["bone_combo_index"]     = (int)sm.boneStart;
                    if (billboard_vertex_count > 0) {
                        batch["has_billboard"] = true;
                        batch["billboard_vertex_count"] = (int)billboard_vertex_count;
                        batch["billboard_bone_flags"] = (int)billboard_flags_seen;
                        batch["billboard_data"] = billboard_data;
                    }
                    batches.push_back(batch);

                    if (include_animation && verts) {
                        PackedVector3Array surface_vertices;
                        PackedVector3Array surface_normals;
                        PackedVector2Array surface_uvs, surface_uvs2;
                        PackedFloat32Array surface_billboard_data;
                        PackedInt32Array surface_bones;
                        PackedFloat32Array surface_weights;
                        PackedInt32Array surface_indices;
                        uint32_t expanded_count = 0;
                        surface_vertices.resize(tri_count);
                        surface_normals.resize(tri_count);
                        surface_uvs.resize(tri_count);
                        surface_uvs2.resize(tri_count);
                        surface_bones.resize(tri_count * 4);
                        surface_weights.resize(tri_count * 4);
                        surface_indices.resize(tri_count);
                        uint32_t surface_billboard_vertex_count = 0;
                        uint32_t surface_billboard_flags_seen = 0;

                        for (uint32_t t = 0; t + 2 < tri_count; t += 3) {
                            uint16_t tri_local[3] = {
                                skin_tri[tri_start + t],
                                skin_tri[tri_start + t + 2],
                                skin_tri[tri_start + t + 1],
                            };
                            for (uint32_t corner = 0; corner < 3; ++corner) {
                                uint16_t vertex_lookup = tri_local[corner];
                                if (vertex_lookup >= skin.nIndices) {
                                    continue;
                                }
                                uint16_t global_vertex_index = skin_idx[vertex_lookup];
                                if (global_vertex_index >= hdr.nVertices) {
                                    continue;
                                }
                                const auto &v = verts[global_vertex_index];
                                surface_vertices[expanded_count] = Vector3(v.pos[0], v.pos[2], -v.pos[1]);
                                surface_normals[expanded_count] = Vector3(v.normal[0], v.normal[2], -v.normal[1]);
                                surface_uvs[expanded_count] = Vector2(v.texCoords[0], v.texCoords[1]);
                                surface_uvs2[expanded_count] = Vector2(v.texCoords2[0], v.texCoords2[1]);
                                surface_indices[expanded_count] = (int)expanded_count;

                                Vector3 billboard_pivot;
                                int billboard_mode = 0;
                                uint32_t billboard_bone_flags = 0;
                                if (resolve_vertex_billboard(
                                            verts,
                                            hdr.nVertices,
                                            vertex_lookup,
                                            global_vertex_index,
                                            skin_bones,
                                            skin.nProperties,
                                            bone_combo_arr,
                                            hdr.nBoneCombos,
                                            sm.boneStart,
                                            bones,
                                            hdr.nBones,
                                            billboard_pivot,
                                            billboard_mode,
                                            billboard_bone_flags)) {
                                    if (surface_billboard_data.is_empty()) {
                                        surface_billboard_data.resize(tri_count * 4);
                                    }
                                    uint32_t dst = expanded_count * 4;
                                    surface_billboard_data[dst] = billboard_pivot.x;
                                    surface_billboard_data[dst + 1] = billboard_pivot.y;
                                    surface_billboard_data[dst + 2] = billboard_pivot.z;
                                    surface_billboard_data[dst + 3] = (float)billboard_mode;
                                    surface_billboard_vertex_count++;
                                    surface_billboard_flags_seen |= (billboard_bone_flags & M2_BONE_BILLBOARD_MASK);
                                }

                                uint32_t weight_sum = 0;
                                for (uint32_t j = 0; j < 4; ++j) {
                                    weight_sum += (uint32_t)v.boneWeights[j];
                                }
                                if (weight_sum == 0) {
                                    weight_sum = 1;
                                }

                                for (uint32_t j = 0; j < 4; ++j) {
                                    int local_bone = (int)v.boneIndices[j];
                                    if (skin_bones && vertex_lookup < skin.nProperties) {
                                        local_bone = (int)skin_bones[vertex_lookup * 4 + j];
                                    }
                                    int global_bone = local_bone;
                                    uint32_t lookup_index = (uint32_t)sm.boneStart + (uint32_t)std::max(local_bone, 0);
                                    if (bone_combo_arr && lookup_index < hdr.nBoneCombos) {
                                        global_bone = (int)bone_combo_arr[lookup_index];
                                    }
                                    surface_bones[expanded_count * 4 + j] = global_bone;
                                    surface_weights[expanded_count * 4 + j] = (float)v.boneWeights[j] / (float)weight_sum;
                                }
                                expanded_count++;
                            }
                        }

                        if (expanded_count > 0) {
                            surface_vertices.resize(expanded_count);
                            surface_normals.resize(expanded_count);
                            surface_uvs.resize(expanded_count);
                            surface_uvs2.resize(expanded_count);
                            surface_bones.resize(expanded_count * 4);
                            surface_weights.resize(expanded_count * 4);
                            surface_indices.resize(expanded_count);
                            Dictionary surface;
                            surface["vertices"] = surface_vertices;
                            surface["normals"] = surface_normals;
                            surface["uvs"] = surface_uvs;
                            surface["uvs2"] = surface_uvs2;
                            if (surface_billboard_vertex_count > 0) {
                                surface_billboard_data.resize(expanded_count * 4);
                                surface["has_billboard"] = true;
                                surface["billboard_vertex_count"] = (int)surface_billboard_vertex_count;
                                surface["billboard_bone_flags"] = (int)surface_billboard_flags_seen;
                                surface["billboard_data"] = surface_billboard_data;
                            }
                            surface["bones"] = surface_bones;
                            surface["weights"] = surface_weights;
                            surface["indices"] = surface_indices;
                            surface["material_id"] = (int)tu[u].materialIndex;
                            surface["texture_combo_index"] = (int)tu[u].textureComboIndex;
                            surface["texture_count"] = (int)tu[u].textureCount;
                            surface["shader_id"] = (int)tu[u].shaderID;
                            surface["texture_coord_combo_index"] = (int)tu[u].textureCoordComboIndex;
                            surface["texture_weight_combo_index"] = (int)tu[u].textureWeightComboIndex;
                            surface["texture_transform_combo_index"] = (int)tu[u].textureTransformComboIndex;
                            surface["material_layer"] = (int)tu[u].materialLayer;
                            surface["color_index"] = (int)tu[u].colorIndex;
                            surface["bone_count"] = (int)sm.boneCount;
                            surface["bone_combo_index"] = (int)sm.boneStart;
                            animated_surfaces.push_back(surface);
                        }
                    }
                }
            }
        } else {
            UtilityFunctions::push_warning("M2Loader: skin file missing or invalid: ",
                to_godot(skin_path));
        }
    } else {
        UtilityFunctions::push_warning("M2Loader: skin file not found: ",
            to_godot(skin_path));
    }

    Array animated_bones;
    Array animated_sequences;
    int animation_sequence_index = -1;
    float animation_length = 0.0f;
    int animation_id = -1;
    uint32_t animation_activity_score = 0;
    if (include_animation) {
        const M2Sequence *seqs = safe_array<M2Sequence>(buf, hdr.ofsAnimations, hdr.nAnimations);
        if (seqs && hdr.nAnimations > 0) {
            std::string lower_path = path;
            std::transform(lower_path.begin(), lower_path.end(), lower_path.begin(), [](unsigned char c) { return (char)std::tolower(c); });
            bool prefer_calm_stand = lower_path.find("gryphonroost") != std::string::npos;
            uint32_t chosen = choose_animated_sequence(buf, seqs, hdr.nAnimations, bones, hdr.nBones, prefer_calm_stand);
            animation_sequence_index = (int)chosen;
            animation_length = (float)seqs[chosen].duration / 1000.0f;
            animation_id = (int)seqs[chosen].id;
            animation_activity_score = sequence_activity_score(buf, bones, hdr.nBones, chosen);
            for (uint32_t i = 0; i < hdr.nAnimations; ++i) {
                Dictionary seq;
                seq["id"] = (int)seqs[i].id;
                seq["variation"] = (int)seqs[i].variationIndex;
                seq["duration"] = (int)seqs[i].duration;
                seq["alias_next"] = (int)seqs[i].aliasNext;
                seq["activity_score"] = (int)sequence_activity_score(buf, bones, hdr.nBones, i);
                animated_sequences.push_back(seq);
            }
        }
        if (bones && hdr.nBones > 0 && animation_sequence_index >= 0) {
            for (uint32_t i = 0; i < hdr.nBones; ++i) {
                Dictionary bone;
                int parent = (bones[i].parentIndex == 0xFFFF) ? -1 : (int)bones[i].parentIndex;
                bone["id"] = (int)bones[i].boneId;
                bone["flags"] = (int)bones[i].flags;
                bone["parent"] = parent;
                bone["pivot"] = wow_vec3_to_godot(bones[i].pivot);
                bone["translation"] = make_vec3_track(buf, bones[i].translation, (uint32_t)animation_sequence_index, false);
                bone["rotation"] = make_quat_track(buf, bones[i].rotation, (uint32_t)animation_sequence_index);
                bone["scale"] = make_vec3_track(buf, bones[i].scale, (uint32_t)animation_sequence_index, true);
                animated_bones.push_back(bone);
            }
        }
    }

    Dictionary result;
    result["textures"]        = textures;
    result["texture_types"]   = texture_types;
    result["texture_flags"]   = texture_flags;
    result["materials"]       = materials;
    result["texture_combos"]  = texture_combos;
    result["texture_coord_combos"] = texture_coord_combos;
    result["texture_weight_combos"] = texture_weight_combos;
    result["texture_transform_combos"] = texture_transform_combos;
    result["texture_combiner_combos"] = texture_combiner_combos;
    result["m2_colors"]       = m2_colors;
    result["texture_weights"] = texture_weights;
    result["texture_transforms"] = texture_transforms;
    result["m2_flags"]        = (int)hdr.flags;
    result["model_path"]      = to_godot(path);
    result["vertices"]        = vertices;
    result["normals"]         = normals;
    result["uvs"]             = uvs;
    result["uvs2"]            = uvs2;
    result["indices"]         = indices;
    result["batches"]         = batches;
    if (include_animation) {
        result["animated_surfaces"] = animated_surfaces;
        result["bone_lookup_table"] = bone_lookup_table;
        result["bones"] = animated_bones;
        result["sequences"] = animated_sequences;
        result["animation_sequence_index"] = animation_sequence_index;
        result["animation_id"] = animation_id;
        result["animation_length"] = animation_length;
        result["animation_activity_score"] = (int)animation_activity_score;
    }
    return result;
}

// ─────────────────────────────────────────────────────────────────────────────
// Public API
// ─────────────────────────────────────────────────────────────────────────────
Dictionary M2Loader::load_m2(const String &path) {
    std::string spath = to_std(path);
    auto buf = read_file(spath);
    if (buf.empty()) {
        UtilityFunctions::push_error("M2Loader: cannot read ", path);
        return Dictionary();
    }
    return parse_m2(buf, spath, false);
}

Dictionary M2Loader::load_m2_animated(const String &path) {
    std::string spath = to_std(path);
    auto buf = read_file(spath);
    if (buf.empty()) {
        UtilityFunctions::push_error("M2Loader: cannot read ", path);
        return Dictionary();
    }
    return parse_m2(buf, spath, true);
}

void M2Loader::_bind_methods() {
    ClassDB::bind_method(D_METHOD("load_m2", "path"), &M2Loader::load_m2);
    ClassDB::bind_method(D_METHOD("load_m2_animated", "path"), &M2Loader::load_m2_animated);
}
