package com.tiedup.remake.client.gltf;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;

/**
 * Parser for binary .glb (glTF 2.0) files.
 * Extracts mesh geometry, skinning data, bone hierarchy, and animations.
 * Filters out meshes named "Player".
 */
public final class GlbParser {

    private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");

    private static final int GLB_MAGIC = 0x46546C67; // "glTF"
    private static final int GLB_VERSION = 2;
    private static final int CHUNK_JSON = 0x4E4F534A; // "JSON"
    private static final int CHUNK_BIN = 0x004E4942; // "BIN\0"

    private GlbParser() {}

    /**
     * Parse a .glb file from an InputStream.
     *
     * @param input     the input stream (will be fully read)
     * @param debugName name for log messages
     * @return parsed GltfData
     * @throws IOException if the file is malformed or I/O fails
     */
    public static GltfData parse(InputStream input, String debugName)
        throws IOException {
        byte[] allBytes = input.readAllBytes();
        ByteBuffer buf = ByteBuffer.wrap(allBytes).order(
            ByteOrder.LITTLE_ENDIAN
        );

        // -- Header --
        int magic = buf.getInt();
        if (magic != GLB_MAGIC) {
            throw new IOException("Not a GLB file: " + debugName);
        }
        int version = buf.getInt();
        if (version != GLB_VERSION) {
            throw new IOException(
                "Unsupported GLB version " + version + " in " + debugName
            );
        }
        int totalLength = buf.getInt();

        // -- JSON chunk --
        int jsonChunkLength = buf.getInt();
        int jsonChunkType = buf.getInt();
        if (jsonChunkType != CHUNK_JSON) {
            throw new IOException("Expected JSON chunk in " + debugName);
        }
        byte[] jsonBytes = new byte[jsonChunkLength];
        buf.get(jsonBytes);
        String jsonStr = new String(jsonBytes, StandardCharsets.UTF_8);
        JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();

        // -- BIN chunk --
        ByteBuffer binData = null;
        if (buf.hasRemaining()) {
            int binChunkLength = buf.getInt();
            int binChunkType = buf.getInt();
            if (binChunkType != CHUNK_BIN) {
                throw new IOException("Expected BIN chunk in " + debugName);
            }
            byte[] binBytes = new byte[binChunkLength];
            buf.get(binBytes);
            binData = ByteBuffer.wrap(binBytes).order(ByteOrder.LITTLE_ENDIAN);
        }
        if (binData == null) {
            throw new IOException("No BIN chunk in " + debugName);
        }

        JsonArray accessors = root.getAsJsonArray("accessors");
        JsonArray bufferViews = root.getAsJsonArray("bufferViews");
        JsonArray nodes = root.getAsJsonArray("nodes");
        JsonArray meshes = root.getAsJsonArray("meshes");

        // -- Find skin --
        JsonArray skins = root.getAsJsonArray("skins");
        if (skins == null || skins.size() == 0) {
            throw new IOException("No skins found in " + debugName);
        }
        JsonObject skin = skins.get(0).getAsJsonObject();
        JsonArray skinJoints = skin.getAsJsonArray("joints");

        // Filter skin joints to only include known deforming bones
        List<Integer> filteredJointNodes = new ArrayList<>();
        int[] skinJointRemap = new int[skinJoints.size()]; // old skin index -> new filtered index
        java.util.Arrays.fill(skinJointRemap, -1);
        for (int j = 0; j < skinJoints.size(); j++) {
            int nodeIdx = skinJoints.get(j).getAsInt();
            JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
            String name = node.has("name")
                ? node.get("name").getAsString()
                : "joint_" + j;
            if (GltfBoneMapper.isKnownBone(name)) {
                skinJointRemap[j] = filteredJointNodes.size();
                filteredJointNodes.add(nodeIdx);
            } else {
                LOGGER.debug(
                    "[GltfPipeline] Skipping non-deforming bone: '{}' (node {})",
                    name,
                    nodeIdx
                );
            }
        }

        int jointCount = filteredJointNodes.size();
        String[] jointNames = new String[jointCount];
        int[] parentJointIndices = new int[jointCount];
        Quaternionf[] restRotations = new Quaternionf[jointCount];
        Vector3f[] restTranslations = new Vector3f[jointCount];

        // Map node index -> joint index (filtered)
        int[] nodeToJoint = new int[nodes.size()];
        java.util.Arrays.fill(nodeToJoint, -1);
        for (int j = 0; j < jointCount; j++) {
            int nodeIdx = filteredJointNodes.get(j);
            nodeToJoint[nodeIdx] = j;
        }

        // Read joint names, rest pose, and build parent mapping
        java.util.Arrays.fill(parentJointIndices, -1);
        for (int j = 0; j < jointCount; j++) {
            int nodeIdx = filteredJointNodes.get(j);
            JsonObject node = nodes.get(nodeIdx).getAsJsonObject();

            jointNames[j] = node.has("name")
                ? node.get("name").getAsString()
                : "joint_" + j;

            // Rest rotation
            if (node.has("rotation")) {
                JsonArray r = node.getAsJsonArray("rotation");
                restRotations[j] = new Quaternionf(
                    r.get(0).getAsFloat(),
                    r.get(1).getAsFloat(),
                    r.get(2).getAsFloat(),
                    r.get(3).getAsFloat()
                );
            } else {
                restRotations[j] = new Quaternionf(); // identity
            }

            // Rest translation
            if (node.has("translation")) {
                JsonArray t = node.getAsJsonArray("translation");
                restTranslations[j] = new Vector3f(
                    t.get(0).getAsFloat(),
                    t.get(1).getAsFloat(),
                    t.get(2).getAsFloat()
                );
            } else {
                restTranslations[j] = new Vector3f();
            }
        }

        // Build parent indices by traversing node children
        for (int ni = 0; ni < nodes.size(); ni++) {
            JsonObject node = nodes.get(ni).getAsJsonObject();
            if (node.has("children")) {
                int parentJoint = nodeToJoint[ni];
                JsonArray children = node.getAsJsonArray("children");
                for (JsonElement child : children) {
                    int childNodeIdx = child.getAsInt();
                    int childJoint = nodeToJoint[childNodeIdx];
                    if (childJoint >= 0 && parentJoint >= 0) {
                        parentJointIndices[childJoint] = parentJoint;
                    }
                }
            }
        }

        // -- Inverse Bind Matrices --
        // IBM accessor is indexed by original skin joint order, so we pick the filtered entries
        Matrix4f[] inverseBindMatrices = new Matrix4f[jointCount];
        if (skin.has("inverseBindMatrices")) {
            int ibmAccessor = skin.get("inverseBindMatrices").getAsInt();
            float[] ibmData = GlbParserUtils.readFloatAccessor(
                accessors,
                bufferViews,
                binData,
                ibmAccessor
            );
            for (int origJ = 0; origJ < skinJoints.size(); origJ++) {
                int newJ = skinJointRemap[origJ];
                if (newJ >= 0) {
                    inverseBindMatrices[newJ] = new Matrix4f();
                    inverseBindMatrices[newJ].set(ibmData, origJ * 16);
                }
            }
        } else {
            for (int j = 0; j < jointCount; j++) {
                inverseBindMatrices[j] = new Matrix4f(); // identity
            }
        }

        // -- Find mesh (ignore "Player" mesh, take LAST non-Player) --
        // WORKAROUND: Takes the LAST non-Player mesh because modelers may leave prototype meshes
        //  in the .glb. Revert to first non-Player mesh once modeler workflow is standardized.
        int targetMeshIdx = -1;
        if (meshes != null) {
            for (int mi = 0; mi < meshes.size(); mi++) {
                JsonObject mesh = meshes.get(mi).getAsJsonObject();
                String meshName = mesh.has("name")
                    ? mesh.get("name").getAsString()
                    : "";
                if (!"Player".equals(meshName)) {
                    targetMeshIdx = mi;
                }
            }
        }

        // -- Parse root material names (for tint channel detection) --
        String[] materialNames = GlbParserUtils.parseMaterialNames(root);

        // Mesh data: empty arrays if no mesh found (animation-only GLB)
        float[] positions;
        float[] normals;
        float[] texCoords;
        int[] indices;
        int vertexCount;
        int[] meshJoints;
        float[] weights;
        List<GltfData.Primitive> parsedPrimitives = new ArrayList<>();

        if (targetMeshIdx >= 0) {
            JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject();
            JsonArray primitives = mesh.getAsJsonArray("primitives");

            // -- Accumulate vertex data from ALL primitives --
            List<float[]> allPositions = new ArrayList<>();
            List<float[]> allNormals = new ArrayList<>();
            List<float[]> allTexCoords = new ArrayList<>();
            List<int[]> allJoints = new ArrayList<>();
            List<float[]> allWeights = new ArrayList<>();
            int cumulativeVertexCount = 0;

            for (int pi = 0; pi < primitives.size(); pi++) {
                JsonObject primitive = primitives.get(pi).getAsJsonObject();
                JsonObject attributes = primitive.getAsJsonObject("attributes");

                // -- Read this primitive's vertex data --
                float[] primPositions = GlbParserUtils.readFloatAccessor(
                    accessors,
                    bufferViews,
                    binData,
                    attributes.get("POSITION").getAsInt()
                );
                float[] primNormals = attributes.has("NORMAL")
                    ? GlbParserUtils.readFloatAccessor(
                          accessors,
                          bufferViews,
                          binData,
                          attributes.get("NORMAL").getAsInt()
                      )
                    : new float[primPositions.length];
                float[] primTexCoords = attributes.has("TEXCOORD_0")
                    ? GlbParserUtils.readFloatAccessor(
                          accessors,
                          bufferViews,
                          binData,
                          attributes.get("TEXCOORD_0").getAsInt()
                      )
                    : new float[(primPositions.length / 3) * 2];

                int primVertexCount = primPositions.length / 3;

                // -- Read this primitive's indices (offset by cumulative vertex count) --
                int[] primIndices;
                if (primitive.has("indices")) {
                    primIndices = GlbParserUtils.readIntAccessor(
                        accessors,
                        bufferViews,
                        binData,
                        primitive.get("indices").getAsInt()
                    );
                } else {
                    // Non-indexed: generate sequential indices
                    primIndices = new int[primVertexCount];
                    for (int i = 0; i < primVertexCount; i++) primIndices[i] =
                        i;
                }

                // Offset indices by cumulative vertex count from prior primitives
                if (cumulativeVertexCount > 0) {
                    for (int i = 0; i < primIndices.length; i++) {
                        primIndices[i] += cumulativeVertexCount;
                    }
                }

                // -- Read skinning attributes for this primitive --
                int[] primJoints = new int[primVertexCount * 4];
                float[] primWeights = new float[primVertexCount * 4];

                if (attributes.has("JOINTS_0")) {
                    primJoints = GlbParserUtils.readIntAccessor(
                        accessors,
                        bufferViews,
                        binData,
                        attributes.get("JOINTS_0").getAsInt()
                    );
                    // Remap vertex joint indices from original skin order to filtered order
                    for (int i = 0; i < primJoints.length; i++) {
                        int origIdx = primJoints[i];
                        if (origIdx >= 0 && origIdx < skinJointRemap.length) {
                            primJoints[i] =
                                skinJointRemap[origIdx] >= 0
                                    ? skinJointRemap[origIdx]
                                    : 0;
                        } else {
                            primJoints[i] = 0;
                        }
                    }
                }
                if (attributes.has("WEIGHTS_0")) {
                    primWeights = GlbParserUtils.readFloatAccessor(
                        accessors,
                        bufferViews,
                        binData,
                        attributes.get("WEIGHTS_0").getAsInt()
                    );
                }

                // -- Resolve material name and tint channel --
                String matName = null;
                if (primitive.has("material")) {
                    int matIdx = primitive.get("material").getAsInt();
                    if (matIdx >= 0 && matIdx < materialNames.length) {
                        matName = materialNames[matIdx];
                    }
                }
                boolean isTintable =
                    matName != null && matName.startsWith("tintable_");
                String tintChannel = isTintable ? matName : null;

                parsedPrimitives.add(
                    new GltfData.Primitive(
                        primIndices,
                        matName,
                        isTintable,
                        tintChannel
                    )
                );

                allPositions.add(primPositions);
                allNormals.add(primNormals);
                allTexCoords.add(primTexCoords);
                allJoints.add(primJoints);
                allWeights.add(primWeights);
                cumulativeVertexCount += primVertexCount;
            }

            // -- Flatten accumulated data into single arrays --
            vertexCount = cumulativeVertexCount;
            positions = GlbParserUtils.flattenFloats(allPositions);
            normals = GlbParserUtils.flattenFloats(allNormals);
            texCoords = GlbParserUtils.flattenFloats(allTexCoords);
            meshJoints = GlbParserUtils.flattenInts(allJoints);
            weights = GlbParserUtils.flattenFloats(allWeights);

            // Build union of all primitive indices (for backward-compat indices() accessor)
            int totalIndices = 0;
            for (GltfData.Primitive p : parsedPrimitives)
                totalIndices += p.indices().length;
            indices = new int[totalIndices];
            int offset = 0;
            for (GltfData.Primitive p : parsedPrimitives) {
                System.arraycopy(
                    p.indices(),
                    0,
                    indices,
                    offset,
                    p.indices().length
                );
                offset += p.indices().length;
            }
        } else {
            // Animation-only GLB: no mesh data
            LOGGER.info(
                "[GltfPipeline] No mesh found in '{}' (animation-only GLB)",
                debugName
            );
            positions = new float[0];
            normals = new float[0];
            texCoords = new float[0];
            indices = new int[0];
            vertexCount = 0;
            meshJoints = new int[0];
            weights = new float[0];
        }

        // -- Read ALL animations --
        Map<String, GltfData.AnimationClip> allClips = new LinkedHashMap<>();
        JsonArray animations = root.getAsJsonArray("animations");
        if (animations != null) {
            for (int ai = 0; ai < animations.size(); ai++) {
                JsonObject anim = animations.get(ai).getAsJsonObject();
                String animName = anim.has("name")
                    ? anim.get("name").getAsString()
                    : "animation_" + ai;
                // Strip the "ArmatureName|" prefix if present (Blender convention)
                if (animName.contains("|")) {
                    animName = animName.substring(
                        animName.lastIndexOf('|') + 1
                    );
                }
                GltfData.AnimationClip clip = parseAnimation(
                    anim,
                    accessors,
                    bufferViews,
                    binData,
                    nodeToJoint,
                    jointCount
                );
                if (clip != null) {
                    allClips.put(animName, clip);
                }
            }
        }

        // Default animation = first clip (for backward compat)
        GltfData.AnimationClip animClip = allClips.isEmpty()
            ? null
            : allClips.values().iterator().next();

        LOGGER.info(
            "[GltfPipeline] Parsed '{}': vertices={}, indices={}, joints={}, animations={}",
            debugName,
            vertexCount,
            indices.length,
            jointCount,
            allClips.size()
        );
        for (String name : allClips.keySet()) {
            LOGGER.debug("[GltfPipeline]   animation: '{}'", name);
        }
        for (int j = 0; j < jointCount; j++) {
            Quaternionf rq = restRotations[j];
            Vector3f rt = restTranslations[j];
            LOGGER.debug(
                String.format(
                    "[GltfPipeline]   joint[%d] = '%s', parent=%d, restQ=(%.3f,%.3f,%.3f,%.3f) restT=(%.3f,%.3f,%.3f)",
                    j,
                    jointNames[j],
                    parentJointIndices[j],
                    rq.x,
                    rq.y,
                    rq.z,
                    rq.w,
                    rt.x,
                    rt.y,
                    rt.z
                )
            );
        }

        // Log animation translation channels for default clip (BEFORE MC conversion)
        if (animClip != null && animClip.translations() != null) {
            Vector3f[][] animTrans = animClip.translations();
            for (int j = 0; j < jointCount; j++) {
                if (j < animTrans.length && animTrans[j] != null) {
                    Vector3f at = animTrans[j][0]; // first frame
                    Vector3f rt = restTranslations[j];
                    LOGGER.debug(
                        String.format(
                            "[GltfPipeline]   joint[%d] '%s' has ANIM TRANSLATION: (%.4f,%.4f,%.4f) vs rest (%.4f,%.4f,%.4f) delta=(%.4f,%.4f,%.4f)",
                            j,
                            jointNames[j],
                            at.x,
                            at.y,
                            at.z,
                            rt.x,
                            rt.y,
                            rt.z,
                            at.x - rt.x,
                            at.y - rt.y,
                            at.z - rt.z
                        )
                    );
                }
            }
        } else {
            LOGGER.debug(
                "[GltfPipeline] Default animation has NO translation channels"
            );
        }

        // Save raw glTF rotations BEFORE coordinate conversion (for pose converter)
        // MC model space faces +Z just like glTF, so delta quaternions for ModelPart
        // rotation should be computed from raw glTF data, not from the converted data.
        Quaternionf[] rawRestRotations = new Quaternionf[jointCount];
        for (int j = 0; j < jointCount; j++) {
            rawRestRotations[j] = new Quaternionf(restRotations[j]);
        }

        // Build raw copies of ALL animation clips (before MC conversion)
        Map<String, GltfData.AnimationClip> rawAllClips = new LinkedHashMap<>();
        for (Map.Entry<
            String,
            GltfData.AnimationClip
        > entry : allClips.entrySet()) {
            rawAllClips.put(
                entry.getKey(),
                GlbParserUtils.deepCopyClip(entry.getValue())
            );
        }
        GltfData.AnimationClip rawAnimClip = rawAllClips.isEmpty()
            ? null
            : rawAllClips.values().iterator().next();

        // Convert from glTF coordinate system (Y-up, faces +Z) to MC (Y-up, faces -Z)
        // This is a 180° rotation around Y: negate X and Z for all spatial data
        // Convert ALL animation clips to MC space
        for (GltfData.AnimationClip clip : allClips.values()) {
            GlbParserUtils.convertAnimationToMinecraftSpace(clip, jointCount);
        }
        convertToMinecraftSpace(
            positions,
            normals,
            restTranslations,
            restRotations,
            inverseBindMatrices,
            null,
            jointCount
        ); // pass null — clips already converted above
        LOGGER.debug(
            "[GltfPipeline] Converted all data to Minecraft coordinate space"
        );

        return new GltfData(
            positions,
            normals,
            texCoords,
            indices,
            meshJoints,
            weights,
            jointNames,
            parentJointIndices,
            inverseBindMatrices,
            restRotations,
            restTranslations,
            rawRestRotations,
            rawAnimClip,
            animClip,
            allClips,
            rawAllClips,
            parsedPrimitives,
            vertexCount,
            jointCount
        );
    }

    // ---- Animation parsing ----

    private static GltfData.AnimationClip parseAnimation(
        JsonObject animation,
        JsonArray accessors,
        JsonArray bufferViews,
        ByteBuffer binData,
        int[] nodeToJoint,
        int jointCount
    ) {
        JsonArray channels = animation.getAsJsonArray("channels");
        JsonArray samplers = animation.getAsJsonArray("samplers");

        // Collect rotation and translation channels
        List<Integer> rotJoints = new ArrayList<>();
        List<float[]> rotTimestamps = new ArrayList<>();
        List<Quaternionf[]> rotValues = new ArrayList<>();

        List<Integer> transJoints = new ArrayList<>();
        List<float[]> transTimestamps = new ArrayList<>();
        List<Vector3f[]> transValues = new ArrayList<>();

        for (JsonElement chElem : channels) {
            JsonObject channel = chElem.getAsJsonObject();
            JsonObject target = channel.getAsJsonObject("target");
            String path = target.get("path").getAsString();

            int nodeIdx = target.get("node").getAsInt();
            if (
                nodeIdx >= nodeToJoint.length || nodeToJoint[nodeIdx] < 0
            ) continue;
            int jointIdx = nodeToJoint[nodeIdx];

            int samplerIdx = channel.get("sampler").getAsInt();
            JsonObject sampler = samplers.get(samplerIdx).getAsJsonObject();

            float[] times = GlbParserUtils.readFloatAccessor(
                accessors,
                bufferViews,
                binData,
                sampler.get("input").getAsInt()
            );

            if ("rotation".equals(path)) {
                float[] quats = GlbParserUtils.readFloatAccessor(
                    accessors,
                    bufferViews,
                    binData,
                    sampler.get("output").getAsInt()
                );
                Quaternionf[] qArr = new Quaternionf[times.length];
                for (int i = 0; i < times.length; i++) {
                    qArr[i] = new Quaternionf(
                        quats[i * 4],
                        quats[i * 4 + 1],
                        quats[i * 4 + 2],
                        quats[i * 4 + 3]
                    );
                }
                rotJoints.add(jointIdx);
                rotTimestamps.add(times);
                rotValues.add(qArr);
            } else if ("translation".equals(path)) {
                float[] vecs = GlbParserUtils.readFloatAccessor(
                    accessors,
                    bufferViews,
                    binData,
                    sampler.get("output").getAsInt()
                );
                Vector3f[] tArr = new Vector3f[times.length];
                for (int i = 0; i < times.length; i++) {
                    tArr[i] = new Vector3f(
                        vecs[i * 3],
                        vecs[i * 3 + 1],
                        vecs[i * 3 + 2]
                    );
                }
                transJoints.add(jointIdx);
                transTimestamps.add(times);
                transValues.add(tArr);
            }
        }

        if (rotJoints.isEmpty() && transJoints.isEmpty()) return null;

        // Use the first available channel's timestamps as reference
        float[] timestamps = !rotTimestamps.isEmpty()
            ? rotTimestamps.get(0)
            : transTimestamps.get(0);
        int frameCount = timestamps.length;

        // Build per-joint rotation arrays (null if no animation for that joint)
        Quaternionf[][] rotations = new Quaternionf[jointCount][];
        for (int i = 0; i < rotJoints.size(); i++) {
            int jIdx = rotJoints.get(i);
            Quaternionf[] vals = rotValues.get(i);
            rotations[jIdx] = new Quaternionf[frameCount];
            for (int f = 0; f < frameCount; f++) {
                rotations[jIdx][f] =
                    f < vals.length ? vals[f] : vals[vals.length - 1];
            }
        }

        // Build per-joint translation arrays (null if no animation for that joint)
        Vector3f[][] translations = new Vector3f[jointCount][];
        for (int i = 0; i < transJoints.size(); i++) {
            int jIdx = transJoints.get(i);
            Vector3f[] vals = transValues.get(i);
            translations[jIdx] = new Vector3f[frameCount];
            for (int f = 0; f < frameCount; f++) {
                translations[jIdx][f] =
                    f < vals.length
                        ? new Vector3f(vals[f])
                        : new Vector3f(vals[vals.length - 1]);
            }
        }

        // Log translation channels found
        if (!transJoints.isEmpty()) {
            LOGGER.debug(
                "[GltfPipeline] Animation has {} translation channel(s)",
                transJoints.size()
            );
        }

        return new GltfData.AnimationClip(
            timestamps,
            rotations,
            translations,
            frameCount
        );
    }

    // ---- Coordinate system conversion ----

    /**
     * Convert all spatial data from glTF space to MC model-def space.
     * The Blender-exported character faces -Z in glTF, same as MC model-def.
     * Only X (right→left) and Y (up→down) differ between the two spaces.
     * Equivalent to a 180° rotation around Z: negate X and Y components.
     *
     * For positions/normals/translations: (x,y,z) → (-x, -y, z)
     * For quaternions: (x,y,z,w) → (-x, -y, z, w) (conjugation by 180° Z)
     * For matrices: M → C * M * C where C = diag(-1, -1, 1, 1)
     */
    private static void convertToMinecraftSpace(
        float[] positions,
        float[] normals,
        Vector3f[] restTranslations,
        Quaternionf[] restRotations,
        Matrix4f[] inverseBindMatrices,
        GltfData.AnimationClip animClip,
        int jointCount
    ) {
        // Vertex positions: negate X and Y
        for (int i = 0; i < positions.length; i += 3) {
            positions[i] = -positions[i]; // X
            positions[i + 1] = -positions[i + 1]; // Y
        }

        // Vertex normals: negate X and Y
        for (int i = 0; i < normals.length; i += 3) {
            normals[i] = -normals[i];
            normals[i + 1] = -normals[i + 1];
        }

        // Rest translations: negate X and Y
        for (Vector3f t : restTranslations) {
            t.x = -t.x;
            t.y = -t.y;
        }

        // Rest rotations: conjugate by 180° Z = negate qx and qy
        for (Quaternionf q : restRotations) {
            q.x = -q.x;
            q.y = -q.y;
        }

        // Inverse bind matrices: C * M * C where C = diag(-1, -1, 1)
        Matrix4f C = new Matrix4f().scaling(-1, -1, 1);
        Matrix4f temp = new Matrix4f();
        for (Matrix4f ibm : inverseBindMatrices) {
            temp.set(C).mul(ibm).mul(C);
            ibm.set(temp);
        }

        // Animation quaternions: same conjugation
        if (animClip != null) {
            Quaternionf[][] rotations = animClip.rotations();
            for (int j = 0; j < jointCount; j++) {
                if (j < rotations.length && rotations[j] != null) {
                    for (Quaternionf q : rotations[j]) {
                        q.x = -q.x;
                        q.y = -q.y;
                    }
                }
            }

            // Animation translations: negate X and Y (same as rest translations)
            Vector3f[][] translations = animClip.translations();
            if (translations != null) {
                for (int j = 0; j < jointCount; j++) {
                    if (j < translations.length && translations[j] != null) {
                        for (Vector3f t : translations[j]) {
                            t.x = -t.x;
                            t.y = -t.y;
                        }
                    }
                }
            }
        }
    }
}
