package com.tiedup.remake.client.gltf;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.List;
import org.jetbrains.annotations.Nullable;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;

/**
 * Shared stateless utilities for parsing binary glTF (.glb) files.
 *
 * <p>These methods are used by both {@link GlbParser} (single-armature bondage meshes)
 * and {@link com.tiedup.remake.v2.furniture.client.FurnitureGlbParser FurnitureGlbParser}
 * (multi-armature furniture meshes). Extracted to eliminate ~160 lines of verbatim
 * duplication between the two parsers.</p>
 *
 * <p>All methods are pure functions (no state, no side effects).</p>
 */
public final class GlbParserUtils {

    // glTF component type constants
    public static final int BYTE = 5120;
    public static final int UNSIGNED_BYTE = 5121;
    public static final int SHORT = 5122;
    public static final int UNSIGNED_SHORT = 5123;
    public static final int UNSIGNED_INT = 5125;
    public static final int FLOAT = 5126;

    /** Maximum allowed GLB file size to prevent OOM from malformed/hostile assets. */
    public static final int MAX_GLB_SIZE = 50 * 1024 * 1024; // 50 MB

    // GLB binary format constants (shared between parsers and validator).
    public static final int GLB_MAGIC = 0x46546C67; // "glTF"
    public static final int GLB_VERSION = 2;
    public static final int CHUNK_JSON = 0x4E4F534A; // "JSON"
    public static final int CHUNK_BIN = 0x004E4942; // "BIN\0"

    private GlbParserUtils() {}

    /**
     * Safely read a GLB stream into a little-endian ByteBuffer positioned past the
     * 12-byte header, after validating magic, version, and total length.
     *
     * <p>Protects downstream parsers from OOM and negative-length crashes on malformed
     * or hostile resource packs. Files larger than {@link #MAX_GLB_SIZE} are rejected.</p>
     *
     * @param input     the input stream (will be read)
     * @param debugName name included in diagnostic messages
     * @return a buffer positioned at the start of the first chunk, with
     *         remaining bytes exactly equal to {@code totalLength - 12}
     * @throws IOException on bad header, size cap exceeded, or truncation
     */
    public static ByteBuffer readGlbSafely(InputStream input, String debugName)
        throws IOException {
        byte[] header = input.readNBytes(12);
        if (header.length < 12) {
            throw new IOException("GLB truncated in header: " + debugName);
        }
        ByteBuffer hdr = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN);
        int magic = hdr.getInt();
        if (magic != GLB_MAGIC) {
            throw new IOException("Not a GLB file: " + debugName);
        }
        int version = hdr.getInt();
        if (version != GLB_VERSION) {
            throw new IOException(
                "Unsupported GLB version " + version + " in " + debugName
            );
        }
        int totalLength = hdr.getInt();
        if (totalLength < 12) {
            throw new IOException(
                "GLB total length " + totalLength + " too small in " + debugName
            );
        }
        if (totalLength > MAX_GLB_SIZE) {
            throw new IOException(
                "GLB size " +
                totalLength +
                " exceeds cap " +
                MAX_GLB_SIZE +
                " in " +
                debugName
            );
        }
        int bodyLen = totalLength - 12;
        byte[] body = input.readNBytes(bodyLen);
        if (body.length < bodyLen) {
            throw new IOException(
                "GLB truncated: expected " +
                bodyLen +
                " body bytes, got " +
                body.length +
                " in " +
                debugName
            );
        }
        return ByteBuffer.wrap(body).order(ByteOrder.LITTLE_ENDIAN);
    }

    /**
     * Normalize per-vertex skinning weights so each 4-tuple sums to 1.0.
     *
     * <p>Blender auto-weights + float quantization often produce sums slightly ≠ 1
     * (commonly 0.98–1.02). LBS without normalization scales the vertex by that
     * factor — tiny error per vertex, visible drift over a full mesh.
     * Tuples that sum to effectively zero (no influence) are left alone so
     * downstream code can treat them as "un-skinned" if needed.</p>
     *
     * <p>Modifies the array in place. Call once at parse time; zero per-frame cost.</p>
     */
    public static void normalizeWeights(float[] weights) {
        if (weights == null) return;
        if (weights.length % 4 != 0) {
            // WEIGHTS_0 is VEC4 per glTF spec; a non-multiple-of-4 array is malformed.
            // We still process the well-formed prefix.
            org.apache.logging.log4j.LogManager.getLogger(
                "GltfPipeline"
            ).warn(
                "[GltfPipeline] WEIGHTS_0 array length {} is not a multiple of 4 (malformed); trailing {} values ignored",
                weights.length,
                weights.length % 4
            );
        }
        for (int i = 0; i + 3 < weights.length; i += 4) {
            float sum =
                weights[i] + weights[i + 1] + weights[i + 2] + weights[i + 3];
            if (sum > 1.0e-6f && Math.abs(sum - 1.0f) > 1.0e-4f) {
                float inv = 1.0f / sum;
                weights[i] *= inv;
                weights[i + 1] *= inv;
                weights[i + 2] *= inv;
                weights[i + 3] *= inv;
            }
        }
    }

    /**
     * Clamp joint indices into the valid range [0, jointCount), remapping
     * out-of-range indices to 0 (root). <b>Mutates {@code joints} in place.</b>
     * Returns the number of clamps performed so the caller can log a single
     * warning when a file is malformed.
     */
    public static int clampJointIndices(int[] joints, int jointCount) {
        if (joints == null) return 0;
        int clamped = 0;
        for (int i = 0; i < joints.length; i++) {
            if (joints[i] < 0 || joints[i] >= jointCount) {
                joints[i] = 0;
                clamped++;
            }
        }
        return clamped;
    }

    /**
     * Read a chunk length from the buffer and validate it fits within remaining bytes.
     * @throws IOException if length is negative or exceeds remaining
     */
    public static int readChunkLength(
        ByteBuffer buf,
        String chunkName,
        String debugName
    ) throws IOException {
        int len = buf.getInt();
        if (len < 0) {
            throw new IOException(
                "Negative " + chunkName + " chunk length in " + debugName
            );
        }
        if (len > buf.remaining() - 4) {
            // -4 for the chunk-type field that follows
            throw new IOException(
                chunkName +
                " chunk length " +
                len +
                " exceeds remaining bytes in " +
                debugName
            );
        }
        return len;
    }

    // ---- Material name parsing ----

    /**
     * Parse the root "materials" array and extract each material's "name" field.
     * Returns an empty array if no materials are present.
     */
    public static String[] parseMaterialNames(JsonObject root) {
        if (!root.has("materials") || !root.get("materials").isJsonArray()) {
            return new String[0];
        }
        JsonArray materials = root.getAsJsonArray("materials");
        String[] names = new String[materials.size()];
        for (int i = 0; i < materials.size(); i++) {
            JsonObject mat = materials.get(i).getAsJsonObject();
            names[i] = mat.has("name") ? mat.get("name").getAsString() : null;
        }
        return names;
    }

    // ---- Array flattening utilities ----

    public static float[] flattenFloats(List<float[]> arrays) {
        int total = 0;
        for (float[] a : arrays) total += a.length;
        float[] result = new float[total];
        int offset = 0;
        for (float[] a : arrays) {
            System.arraycopy(a, 0, result, offset, a.length);
            offset += a.length;
        }
        return result;
    }

    public static int[] flattenInts(List<int[]> arrays) {
        int total = 0;
        for (int[] a : arrays) total += a.length;
        int[] result = new int[total];
        int offset = 0;
        for (int[] a : arrays) {
            System.arraycopy(a, 0, result, offset, a.length);
            offset += a.length;
        }
        return result;
    }

    // ---- Accessor reading utilities ----

    public static float[] readFloatAccessor(
        JsonArray accessors,
        JsonArray bufferViews,
        ByteBuffer binData,
        int accessorIdx
    ) {
        JsonObject accessor = accessors.get(accessorIdx).getAsJsonObject();
        int count = accessor.get("count").getAsInt();
        int componentType = accessor.get("componentType").getAsInt();
        String type = accessor.get("type").getAsString();
        int components = typeComponents(type);

        int bvIdx = accessor.get("bufferView").getAsInt();
        JsonObject bv = bufferViews.get(bvIdx).getAsJsonObject();
        int byteOffset =
            (bv.has("byteOffset") ? bv.get("byteOffset").getAsInt() : 0) +
            (accessor.has("byteOffset")
                ? accessor.get("byteOffset").getAsInt()
                : 0);
        int byteStride = bv.has("byteStride")
            ? bv.get("byteStride").getAsInt()
            : 0;

        int componentSize = componentByteSize(componentType);
        int stride = byteStride > 0 ? byteStride : components * componentSize;
        int totalElements = validateAccessorBounds(
            count,
            components,
            componentSize,
            byteOffset,
            stride,
            binData.capacity()
        );
        float[] result = new float[totalElements];

        // Seek once per element; the sequential reads in readComponentAsFloat
        // advance the buffer through the components. Explicit per-component
        // seeks are redundant because component c+1 is already at the right
        // offset after reading c.
        for (int i = 0; i < count; i++) {
            binData.position(byteOffset + i * stride);
            for (int c = 0; c < components; c++) {
                result[i * components + c] = readComponentAsFloat(
                    binData,
                    componentType
                );
            }
        }

        return result;
    }

    public static int[] readIntAccessor(
        JsonArray accessors,
        JsonArray bufferViews,
        ByteBuffer binData,
        int accessorIdx
    ) {
        JsonObject accessor = accessors.get(accessorIdx).getAsJsonObject();
        int count = accessor.get("count").getAsInt();
        int componentType = accessor.get("componentType").getAsInt();
        String type = accessor.get("type").getAsString();
        int components = typeComponents(type);

        int bvIdx = accessor.get("bufferView").getAsInt();
        JsonObject bv = bufferViews.get(bvIdx).getAsJsonObject();
        int byteOffset =
            (bv.has("byteOffset") ? bv.get("byteOffset").getAsInt() : 0) +
            (accessor.has("byteOffset")
                ? accessor.get("byteOffset").getAsInt()
                : 0);
        int byteStride = bv.has("byteStride")
            ? bv.get("byteStride").getAsInt()
            : 0;

        int componentSize = componentByteSize(componentType);
        int stride = byteStride > 0 ? byteStride : components * componentSize;
        int totalElements = validateAccessorBounds(
            count,
            components,
            componentSize,
            byteOffset,
            stride,
            binData.capacity()
        );
        int[] result = new int[totalElements];

        // Seek once per element — see readFloatAccessor comment.
        for (int i = 0; i < count; i++) {
            binData.position(byteOffset + i * stride);
            for (int c = 0; c < components; c++) {
                result[i * components + c] = readComponentAsInt(
                    binData,
                    componentType
                );
            }
        }

        return result;
    }

    /**
     * Reject malformed/hostile accessors before allocating. Prevents OOM from a
     * JSON declaring e.g. {@code count=5e8, type=MAT4} (~8 GB) within a legitimately
     * sized GLB. Checks (all must hold):
     * <ul>
     *   <li>{@code count >= 0} and {@code components >= 1}</li>
     *   <li>{@code count * components} doesn't overflow int</li>
     *   <li>{@code byteOffset + stride * (count - 1) + components * componentSize <= binCapacity}</li>
     * </ul>
     *
     * @return {@code count * components} (the allocation size)
     */
    private static int validateAccessorBounds(
        int count,
        int components,
        int componentSize,
        int byteOffset,
        int stride,
        int binCapacity
    ) {
        if (count < 0) {
            throw new IllegalArgumentException(
                "Accessor count must be non-negative: " + count
            );
        }
        if (components < 1) {
            throw new IllegalArgumentException(
                "Accessor components must be >= 1: " + components
            );
        }
        if (byteOffset < 0 || stride < 0 || componentSize < 1) {
            throw new IllegalArgumentException(
                "Accessor has negative byteOffset/stride or zero componentSize"
            );
        }
        int totalElements;
        try {
            totalElements = Math.multiplyExact(count, components);
        } catch (ArithmeticException overflow) {
            throw new IllegalArgumentException(
                "Accessor count * components overflows int: " +
                count +
                " * " +
                components
            );
        }
        if (count == 0) {
            return 0;
        }
        // Bytes required: byteOffset + stride*(count-1) + components*componentSize
        long lastElementStart =
            (long) byteOffset + (long) stride * (long) (count - 1);
        long elementBytes = (long) components * (long) componentSize;
        long required = lastElementStart + elementBytes;
        if (required > binCapacity) {
            throw new IllegalArgumentException(
                "Accessor would read past BIN chunk: needs " +
                required +
                " bytes, buffer has " +
                binCapacity
            );
        }
        return totalElements;
    }

    /**
     * Read one component as a normalized float in [−1, 1] (signed) or [0, 1]
     * (unsigned). {@code UNSIGNED_INT} is defensive — glTF 2.0 §3.6.2.3 only
     * lists BYTE/UBYTE/SHORT/USHORT as valid normalized types; an out-of-spec
     * exporter hitting that branch gets a best-effort divide by 0xFFFFFFFF.
     */
    public static float readComponentAsFloat(
        ByteBuffer buf,
        int componentType
    ) {
        return switch (componentType) {
            case FLOAT -> buf.getFloat();
            case BYTE -> buf.get() / 127.0f;
            case UNSIGNED_BYTE -> (buf.get() & 0xFF) / 255.0f;
            case SHORT -> buf.getShort() / 32767.0f;
            case UNSIGNED_SHORT -> (buf.getShort() & 0xFFFF) / 65535.0f;
            case UNSIGNED_INT -> (buf.getInt() & 0xFFFFFFFFL) /
            (float) 0xFFFFFFFFL;
            default -> throw new IllegalArgumentException(
                "Unknown component type: " + componentType
            );
        };
    }

    public static int readComponentAsInt(ByteBuffer buf, int componentType) {
        return switch (componentType) {
            case BYTE -> buf.get();
            case UNSIGNED_BYTE -> buf.get() & 0xFF;
            case SHORT -> buf.getShort();
            case UNSIGNED_SHORT -> buf.getShort() & 0xFFFF;
            case UNSIGNED_INT -> buf.getInt();
            case FLOAT -> (int) buf.getFloat();
            default -> throw new IllegalArgumentException(
                "Unknown component type: " + componentType
            );
        };
    }

    public static int typeComponents(String type) {
        return switch (type) {
            case "SCALAR" -> 1;
            case "VEC2" -> 2;
            case "VEC3" -> 3;
            case "VEC4" -> 4;
            case "MAT4" -> 16;
            default -> throw new IllegalArgumentException(
                "Unknown accessor type: " + type
            );
        };
    }

    public static int componentByteSize(int componentType) {
        return switch (componentType) {
            case BYTE, UNSIGNED_BYTE -> 1;
            case SHORT, UNSIGNED_SHORT -> 2;
            case UNSIGNED_INT, FLOAT -> 4;
            default -> throw new IllegalArgumentException(
                "Unknown component type: " + componentType
            );
        };
    }

    // ---- Deep-copy utility ----

    /**
     * Deep-copy an AnimationClip (preserves original data before MC conversion).
     */
    public static GltfData.AnimationClip deepCopyClip(
        GltfData.AnimationClip clip
    ) {
        Quaternionf[][] rawRotations =
            new Quaternionf[clip.rotations().length][];
        for (int j = 0; j < clip.rotations().length; j++) {
            if (clip.rotations()[j] != null) {
                rawRotations[j] = new Quaternionf[clip.rotations()[j].length];
                for (int f = 0; f < clip.rotations()[j].length; f++) {
                    rawRotations[j][f] = new Quaternionf(
                        clip.rotations()[j][f]
                    );
                }
            }
        }
        Vector3f[][] rawTranslations = null;
        if (clip.translations() != null) {
            rawTranslations = new Vector3f[clip.translations().length][];
            for (int j = 0; j < clip.translations().length; j++) {
                if (clip.translations()[j] != null) {
                    rawTranslations[j] =
                        new Vector3f[clip.translations()[j].length];
                    for (int f = 0; f < clip.translations()[j].length; f++) {
                        rawTranslations[j][f] = new Vector3f(
                            clip.translations()[j][f]
                        );
                    }
                }
            }
        }
        return new GltfData.AnimationClip(
            clip.timestamps().clone(),
            rawRotations,
            rawTranslations,
            clip.frameCount()
        );
    }

    // ---- Naming conventions ----

    /**
     * True when a mesh name follows the {@code Player*} convention — typically
     * a player-armature mesh or seat-armature mesh that the item/furniture
     * pipelines must skip. Null-safe.
     *
     * <p>Historically {@link GlbParser} and {@link
     * com.tiedup.remake.client.gltf.diagnostic.GlbValidator GlbValidator}
     * used {@code "Player".equals(name)} while
     * {@code FurnitureGlbParser} used {@code startsWith("Player")}, so a mesh
     * named {@code "Player_foo"} was accepted by the item pipeline but
     * rejected by the furniture pipeline. Consolidated on the more defensive
     * {@code startsWith} variant — matches the artist guide's
     * {@code Player_*} seat armature naming convention.</p>
     */
    public static boolean isPlayerMesh(@Nullable String meshName) {
        return meshName != null && meshName.startsWith("Player");
    }

    /**
     * Strip a Blender-style armature prefix from a bone or animation name.
     * {@code "ArmatureName|bone"} → {@code "bone"}. Keeps everything after
     * the last {@code |}. Returns the input unchanged if it has no pipe,
     * and returns null if the input is null.
     *
     * <p>All joint/animation/validator reads must go through this helper —
     * a site that stores the raw prefixed name silently breaks artists
     * with non-default armature names.</p>
     */
    public static String stripArmaturePrefix(@Nullable String name) {
        if (name == null) return null;
        int pipeIdx = name.lastIndexOf('|');
        return pipeIdx >= 0 ? name.substring(pipeIdx + 1) : name;
    }

    // ---- Node rest pose extraction ----

    /**
     * Read a node's rest rotation from its glTF JSON representation. Returns
     * identity quaternion (0, 0, 0, 1) when no {@code rotation} field is present.
     *
     * <p>Extracted from 3 call sites in {@link GlbParser} /
     * {@link com.tiedup.remake.v2.furniture.client.FurnitureGlbParser
     * FurnitureGlbParser} that all had identical logic. Each parser's per-joint
     * loop now delegates here to avoid silent drift.</p>
     */
    public static Quaternionf readRestRotation(JsonObject node) {
        if (node != null && node.has("rotation")) {
            JsonArray r = node.getAsJsonArray("rotation");
            return new Quaternionf(
                r.get(0).getAsFloat(),
                r.get(1).getAsFloat(),
                r.get(2).getAsFloat(),
                r.get(3).getAsFloat()
            );
        }
        return new Quaternionf(); // identity
    }

    /**
     * Read a node's rest translation. Returns the zero vector when no
     * {@code translation} field is present. See {@link #readRestRotation}.
     */
    public static Vector3f readRestTranslation(JsonObject node) {
        if (node != null && node.has("translation")) {
            JsonArray t = node.getAsJsonArray("translation");
            return new Vector3f(
                t.get(0).getAsFloat(),
                t.get(1).getAsFloat(),
                t.get(2).getAsFloat()
            );
        }
        return new Vector3f();
    }

    // ---- Bone hierarchy ----

    /**
     * Build the parent-joint index array by traversing the glTF node children.
     *
     * @param nodes        the top-level {@code nodes} array from the glTF JSON
     * @param nodeToJoint  mapping: {@code nodeIdx → jointIdx} (use {@code -1}
     *                     for nodes that are not part of the skin)
     * @param jointCount   the size of the resulting array
     * @return array of size {@code jointCount} where index {@code j} holds the
     *         parent joint index, or {@code -1} for roots
     */
    public static int[] buildParentJointIndices(
        JsonArray nodes,
        int[] nodeToJoint,
        int jointCount
    ) {
        int[] parentJointIndices = new int[jointCount];
        for (int j = 0; j < jointCount; j++) parentJointIndices[j] = -1;

        for (int ni = 0; ni < nodes.size(); ni++) {
            JsonObject node = nodes.get(ni).getAsJsonObject();
            if (!node.has("children")) continue;
            int parentJoint = nodeToJoint[ni];
            JsonArray children = node.getAsJsonArray("children");
            for (JsonElement child : children) {
                int childNodeIdx = child.getAsInt();
                // Malformed GLBs may declare a child index outside `nodes`;
                // silently skip rather than AIOOBE.
                if (childNodeIdx < 0 || childNodeIdx >= nodeToJoint.length) {
                    continue;
                }
                int childJoint = nodeToJoint[childNodeIdx];
                if (childJoint >= 0 && parentJoint >= 0) {
                    parentJointIndices[childJoint] = parentJoint;
                }
            }
        }
        return parentJointIndices;
    }

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

    /**
     * Parse a single {@code glTF animation} JSON object into an
     * {@link GltfData.AnimationClip}. Returns {@code null} when the animation
     * has no channels that map to the current skin.
     *
     * <p>Skin-specific filtering is encoded in {@code nodeToJoint}: channels
     * targeting a node with a {@code -1} mapping are skipped.</p>
     *
     * @param animation    a single entry from the root {@code animations} array
     * @param accessors    the root {@code accessors} array
     * @param bufferViews  the root {@code bufferViews} array
     * @param binData      the BIN chunk buffer
     * @param nodeToJoint  mapping from glTF node index to joint index; use
     *                     {@code -1} for nodes not in the skin
     * @param jointCount   size of the skin
     * @return parsed clip, or {@code null} if no channels matched the skin
     */
    @Nullable
    public 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");

        java.util.List<Integer> rotJoints = new java.util.ArrayList<>();
        java.util.List<float[]> rotTimestamps = new java.util.ArrayList<>();
        java.util.List<Quaternionf[]> rotValues = new java.util.ArrayList<>();

        java.util.List<Integer> transJoints = new java.util.ArrayList<>();
        java.util.List<float[]> transTimestamps = new java.util.ArrayList<>();
        java.util.List<Vector3f[]> transValues = new java.util.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 = readFloatAccessor(
                accessors,
                bufferViews,
                binData,
                sampler.get("input").getAsInt()
            );

            if ("rotation".equals(path)) {
                float[] quats = 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 = 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;

        float[] timestamps = !rotTimestamps.isEmpty()
            ? rotTimestamps.get(0)
            : transTimestamps.get(0);
        int frameCount = timestamps.length;

        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];
            }
        }

        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]);
            }
        }

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

    // ---- Primitive mesh parsing ----

    /**
     * Result of parsing a mesh's primitives: flat per-attribute arrays plus
     * per-primitive metadata. All array lengths are in sync with
     * {@link #vertexCount}.
     *
     * <p>When the parsing request had {@code readSkinning = false} (mesh-only
     * path used by {@code FurnitureGlbParser.buildMeshOnlyGltfData}), the
     * {@link #joints} and {@link #weights} arrays are empty.</p>
     */
    public static final class PrimitiveParseResult {

        public final float[] positions;
        public final float[] normals;
        public final float[] texCoords;
        public final int[] indices;
        public final int[] joints;
        public final float[] weights;
        public final List<GltfData.Primitive> primitives;
        public final int vertexCount;

        PrimitiveParseResult(
            float[] positions,
            float[] normals,
            float[] texCoords,
            int[] indices,
            int[] joints,
            float[] weights,
            List<GltfData.Primitive> primitives,
            int vertexCount
        ) {
            this.positions = positions;
            this.normals = normals;
            this.texCoords = texCoords;
            this.indices = indices;
            this.joints = joints;
            this.weights = weights;
            this.primitives = primitives;
            this.vertexCount = vertexCount;
        }
    }

    /**
     * Parse every primitive of a mesh, accumulate per-attribute buffers, and
     * flatten the result.
     *
     * <p>Invariants:</p>
     * <ul>
     *   <li>POSITION is required. NORMAL and TEXCOORD_0 are optional and
     *       default to zero-filled arrays of the correct size.</li>
     *   <li>Per-primitive indices are offset by the running
     *       {@code cumulativeVertexCount} so the flat arrays index
     *       correctly.</li>
     *   <li>{@code JOINTS_0} is read, out-of-range indices clamped to 0 with
     *       a WARN log (once per file, via {@link #clampJointIndices}).</li>
     *   <li>{@code WEIGHTS_0} is read and normalized per-vertex.</li>
     *   <li>Material tintability: name prefix {@code "tintable_"} → per-channel
     *       {@link GltfData.Primitive} entry.</li>
     * </ul>
     *
     * @param mesh            a single entry from the root {@code meshes} array
     * @param accessors       root {@code accessors} array
     * @param bufferViews     root {@code bufferViews} array
     * @param binData         the BIN chunk buffer
     * @param jointCount      skin joint count (used to clamp JOINTS_0); pass
     *                        {@code 0} when {@code readSkinning} is false
     * @param readSkinning    true to read JOINTS_0 / WEIGHTS_0, false for
     *                        mesh-only (furniture placer fallback)
     * @param materialNames   material name lookup (from
     *                        {@link #parseMaterialNames}); may contain nulls
     * @param debugName       file/resource name for diagnostics
     * @return the consolidated parse result
     */
    public static PrimitiveParseResult parsePrimitives(
        JsonObject mesh,
        JsonArray accessors,
        JsonArray bufferViews,
        ByteBuffer binData,
        int jointCount,
        boolean readSkinning,
        String[] materialNames,
        String debugName
    ) {
        JsonArray primitives = mesh.getAsJsonArray("primitives");

        java.util.List<float[]> allPositions = new java.util.ArrayList<>();
        java.util.List<float[]> allNormals = new java.util.ArrayList<>();
        java.util.List<float[]> allTexCoords = new java.util.ArrayList<>();
        java.util.List<int[]> allJoints = new java.util.ArrayList<>();
        java.util.List<float[]> allWeights = new java.util.ArrayList<>();
        java.util.List<GltfData.Primitive> parsedPrimitives =
            new java.util.ArrayList<>();
        int cumulativeVertexCount = 0;

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

            float[] primPositions = readFloatAccessor(
                accessors,
                bufferViews,
                binData,
                attributes.get("POSITION").getAsInt()
            );
            float[] primNormals = attributes.has("NORMAL")
                ? readFloatAccessor(
                      accessors,
                      bufferViews,
                      binData,
                      attributes.get("NORMAL").getAsInt()
                  )
                : new float[primPositions.length];
            float[] primTexCoords = attributes.has("TEXCOORD_0")
                ? readFloatAccessor(
                      accessors,
                      bufferViews,
                      binData,
                      attributes.get("TEXCOORD_0").getAsInt()
                  )
                : new float[(primPositions.length / 3) * 2];

            int primVertexCount = primPositions.length / 3;

            int[] primIndices;
            if (primitive.has("indices")) {
                primIndices = readIntAccessor(
                    accessors,
                    bufferViews,
                    binData,
                    primitive.get("indices").getAsInt()
                );
            } else {
                primIndices = new int[primVertexCount];
                for (int i = 0; i < primVertexCount; i++) primIndices[i] = i;
            }
            if (cumulativeVertexCount > 0) {
                for (int i = 0; i < primIndices.length; i++) {
                    primIndices[i] += cumulativeVertexCount;
                }
            }

            int[] primJoints = new int[primVertexCount * 4];
            float[] primWeights = new float[primVertexCount * 4];
            if (readSkinning) {
                if (attributes.has("JOINTS_0")) {
                    primJoints = readIntAccessor(
                        accessors,
                        bufferViews,
                        binData,
                        attributes.get("JOINTS_0").getAsInt()
                    );
                    int clamped = clampJointIndices(primJoints, jointCount);
                    if (clamped > 0) {
                        org.apache.logging.log4j.LogManager.getLogger(
                            "GltfPipeline"
                        ).warn(
                            "[GltfPipeline] Clamped {} out-of-range joint indices in '{}'",
                            clamped,
                            debugName
                        );
                    }
                }
                if (attributes.has("WEIGHTS_0")) {
                    primWeights = readFloatAccessor(
                        accessors,
                        bufferViews,
                        binData,
                        attributes.get("WEIGHTS_0").getAsInt()
                    );
                    normalizeWeights(primWeights);
                }
            }

            String matName = null;
            if (primitive.has("material")) {
                int matIdx = primitive.get("material").getAsInt();
                if (
                    matIdx >= 0 &&
                    materialNames != null &&
                    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);
            if (readSkinning) {
                allJoints.add(primJoints);
                allWeights.add(primWeights);
            }
            cumulativeVertexCount += primVertexCount;
        }

        int totalIndices = 0;
        for (GltfData.Primitive p : parsedPrimitives)
            totalIndices += p.indices().length;
        int[] 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;
        }

        return new PrimitiveParseResult(
            flattenFloats(allPositions),
            flattenFloats(allNormals),
            flattenFloats(allTexCoords),
            indices,
            readSkinning ? flattenInts(allJoints) : new int[0],
            readSkinning ? flattenFloats(allWeights) : new float[0],
            parsedPrimitives,
            cumulativeVertexCount
        );
    }

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

    /**
     * Convert mesh-level spatial data from glTF space to Minecraft model-def
     * space. glTF and MC model-def both face -Z; only X (right→left) and Y
     * (up→down) differ. Equivalent to a 180° rotation around Z: negate X and Y.
     *
     * <p>For animation data, see {@link #convertAnimationToMinecraftSpace}.</p>
     *
     * <ul>
     *   <li>Vertex positions / normals: (x, y, z) → (-x, -y, z)</li>
     *   <li>Rest translations: same negation</li>
     *   <li>Rest rotations (quaternions): negate qx and qy (conjugation by 180° Z)</li>
     *   <li>Inverse bind matrices: M → C·M·C where C = diag(-1, -1, 1)</li>
     * </ul>
     */
    public static void convertMeshToMinecraftSpace(
        float[] positions,
        float[] normals,
        Vector3f[] restTranslations,
        Quaternionf[] restRotations,
        Matrix4f[] inverseBindMatrices
    ) {
        for (int i = 0; i < positions.length; i += 3) {
            positions[i] = -positions[i];
            positions[i + 1] = -positions[i + 1];
        }
        for (int i = 0; i < normals.length; i += 3) {
            normals[i] = -normals[i];
            normals[i + 1] = -normals[i + 1];
        }
        for (Vector3f t : restTranslations) {
            t.x = -t.x;
            t.y = -t.y;
        }
        for (Quaternionf q : restRotations) {
            q.x = -q.x;
            q.y = -q.y;
        }
        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);
        }
    }

    /**
     * Convert an animation clip's rotations and translations to MC space.
     * Negate qx/qy for rotations and negate tx/ty for translations.
     */
    public static void convertAnimationToMinecraftSpace(
        GltfData.AnimationClip clip,
        int jointCount
    ) {
        if (clip == null) return;

        Quaternionf[][] rotations = clip.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;
                }
            }
        }

        Vector3f[][] translations = clip.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;
                    }
                }
            }
        }
    }
}
