package com.tiedup.remake.client.gltf;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

/**
 * Unit tests for {@link GlbParserUtils}.
 *
 * <p>Covers the parser safety surface introduced to defeat the OOM / silent-drift
 * bugs found in the 2026-04-17 audit:</p>
 * <ul>
 *   <li>{@link GlbParserUtils#readGlbSafely(java.io.InputStream, String)} header cap</li>
 *   <li>{@link GlbParserUtils#readChunkLength(java.nio.ByteBuffer, String, String)} bounds</li>
 *   <li>{@link GlbParserUtils#normalizeWeights(float[])} correctness</li>
 *   <li>{@link GlbParserUtils#clampJointIndices(int[], int)} contract</li>
 * </ul>
 */
class GlbParserUtilsTest {

    /**
     * Build a minimum-size GLB header: 4B magic, 4B version, 4B totalLength.
     * Returns the 12 bytes; no chunks follow.
     */
    private static byte[] minimalHeader(int totalLength) {
        ByteBuffer buf = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN);
        buf.putInt(GlbParserUtils.GLB_MAGIC);
        buf.putInt(GlbParserUtils.GLB_VERSION);
        buf.putInt(totalLength);
        return buf.array();
    }

    @Nested
    @DisplayName("readGlbSafely — OOM/truncation guards")
    class ReadGlbSafelyTests {

        @Test
        @DisplayName("rejects files exceeding MAX_GLB_SIZE cap")
        void rejectsOversized() {
            // Claim a totalLength 10x the cap, but only provide 12 bytes of header.
            byte[] header = minimalHeader(GlbParserUtils.MAX_GLB_SIZE + 1);
            IOException ex = assertThrows(IOException.class, () ->
                GlbParserUtils.readGlbSafely(
                    new ByteArrayInputStream(header),
                    "hostile.glb"
                )
            );
            assertTrue(
                ex.getMessage().contains("exceeds cap"),
                "message should mention cap: " + ex.getMessage()
            );
        }

        @Test
        @DisplayName("rejects negative totalLength")
        void rejectsNegativeTotalLength() {
            byte[] header = minimalHeader(-1);
            IOException ex = assertThrows(IOException.class, () ->
                GlbParserUtils.readGlbSafely(
                    new ByteArrayInputStream(header),
                    "negative.glb"
                )
            );
            // Either the "too small" or "exceeds cap" path is acceptable —
            // what matters is that negative lengths don't slip through.
            assertTrue(
                ex.getMessage().contains("too small") ||
                    ex.getMessage().contains("exceeds cap"),
                "message should reject negative length: " + ex.getMessage()
            );
        }

        @Test
        @DisplayName("rejects wrong magic bytes")
        void rejectsWrongMagic() {
            ByteBuffer buf = ByteBuffer.allocate(12).order(
                ByteOrder.LITTLE_ENDIAN
            );
            buf.putInt(0xDEADBEEF); // wrong magic
            buf.putInt(GlbParserUtils.GLB_VERSION);
            buf.putInt(100);
            IOException ex = assertThrows(IOException.class, () ->
                GlbParserUtils.readGlbSafely(
                    new ByteArrayInputStream(buf.array()),
                    "junk.glb"
                )
            );
            assertTrue(
                ex.getMessage().contains("Not a GLB"),
                "message should reject bad magic: " + ex.getMessage()
            );
        }

        @Test
        @DisplayName("rejects wrong version")
        void rejectsWrongVersion() {
            ByteBuffer buf = ByteBuffer.allocate(12).order(
                ByteOrder.LITTLE_ENDIAN
            );
            buf.putInt(GlbParserUtils.GLB_MAGIC);
            buf.putInt(999); // future version
            buf.putInt(100);
            IOException ex = assertThrows(IOException.class, () ->
                GlbParserUtils.readGlbSafely(
                    new ByteArrayInputStream(buf.array()),
                    "future.glb"
                )
            );
            assertTrue(
                ex.getMessage().contains("Unsupported GLB version"),
                "message should mention version: " + ex.getMessage()
            );
        }

        @Test
        @DisplayName("rejects truncated body (totalLength promises more than stream provides)")
        void rejectsTruncatedBody() {
            // Claim 100 bytes total, but only supply the 12-byte header.
            byte[] header = minimalHeader(100);
            IOException ex = assertThrows(IOException.class, () ->
                GlbParserUtils.readGlbSafely(
                    new ByteArrayInputStream(header),
                    "truncated.glb"
                )
            );
            assertTrue(
                ex.getMessage().contains("truncated"),
                "message should mention truncation: " + ex.getMessage()
            );
        }
    }

    @Nested
    @DisplayName("readChunkLength — buffer bounds")
    class ReadChunkLengthTests {

        @Test
        @DisplayName("rejects negative length")
        void rejectsNegativeLength() {
            ByteBuffer buf = ByteBuffer.allocate(8).order(
                ByteOrder.LITTLE_ENDIAN
            );
            buf.putInt(-5); // negative length
            buf.putInt(GlbParserUtils.CHUNK_JSON); // type follows, space reserved
            buf.position(0);
            assertThrows(IOException.class, () ->
                GlbParserUtils.readChunkLength(buf, "JSON", "bad.glb")
            );
        }

        @Test
        @DisplayName("rejects length exceeding remaining")
        void rejectsOverflowingLength() {
            // Buffer capacity 16: 4B length + 4B type + 8B payload.
            // Claim length=100 which doesn't fit.
            ByteBuffer buf = ByteBuffer.allocate(16).order(
                ByteOrder.LITTLE_ENDIAN
            );
            buf.putInt(100);
            buf.putInt(GlbParserUtils.CHUNK_JSON);
            buf.position(0);
            assertThrows(IOException.class, () ->
                GlbParserUtils.readChunkLength(buf, "JSON", "overflow.glb")
            );
        }

        @Test
        @DisplayName("accepts valid length")
        void acceptsValidLength() throws IOException {
            ByteBuffer buf = ByteBuffer.allocate(16).order(
                ByteOrder.LITTLE_ENDIAN
            );
            buf.putInt(8); // 8 bytes payload (will follow)
            buf.putInt(GlbParserUtils.CHUNK_JSON);
            buf.position(0);
            // 4 (type) + 8 (payload) == 12 remaining after length read; len=8 must
            // be <= remaining-4 = 8. Passes.
            int len = GlbParserUtils.readChunkLength(buf, "JSON", "ok.glb");
            assertEquals(8, len);
        }
    }

    @Nested
    @DisplayName("normalizeWeights")
    class NormalizeWeightsTests {

        @Test
        @DisplayName("normalizes a tuple summing to < 1.0")
        void normalizesLowSum() {
            float[] w = { 0.5f, 0.3f, 0.1f, 0.0f }; // sum 0.9
            GlbParserUtils.normalizeWeights(w);
            float sum = w[0] + w[1] + w[2] + w[3];
            assertEquals(1.0f, sum, 1.0e-5f);
        }

        @Test
        @DisplayName("normalizes a tuple summing to > 1.0")
        void normalizesHighSum() {
            float[] w = { 0.6f, 0.4f, 0.2f, 0.0f }; // sum 1.2
            GlbParserUtils.normalizeWeights(w);
            float sum = w[0] + w[1] + w[2] + w[3];
            assertEquals(1.0f, sum, 1.0e-5f);
        }

        @Test
        @DisplayName("leaves already-normalized tuple within ε untouched")
        void leavesNormalizedUntouched() {
            float[] w = { 0.4f, 0.3f, 0.2f, 0.1f }; // sum exactly 1.0
            float[] orig = w.clone();
            GlbParserUtils.normalizeWeights(w);
            assertArrayEquals(orig, w, 1.0e-6f);
        }

        @Test
        @DisplayName("leaves zero-sum tuple alone (un-skinned vertex)")
        void leavesZeroSumAlone() {
            float[] w = { 0.0f, 0.0f, 0.0f, 0.0f };
            GlbParserUtils.normalizeWeights(w);
            float sum = w[0] + w[1] + w[2] + w[3];
            assertEquals(0.0f, sum, 1.0e-6f);
        }

        @Test
        @DisplayName("processes multiple tuples independently")
        void multipleTuples() {
            float[] w = {
                0.5f, 0.3f, 0.1f, 0.0f, // tuple 1: sum 0.9
                0.6f, 0.4f, 0.2f, 0.0f, // tuple 2: sum 1.2
                0.4f, 0.3f, 0.2f, 0.1f, // tuple 3: sum 1.0 (untouched)
            };
            GlbParserUtils.normalizeWeights(w);
            assertEquals(1.0f, w[0] + w[1] + w[2] + w[3], 1.0e-5f);
            assertEquals(1.0f, w[4] + w[5] + w[6] + w[7], 1.0e-5f);
            assertEquals(1.0f, w[8] + w[9] + w[10] + w[11], 1.0e-5f);
        }

        @Test
        @DisplayName("tolerates non-multiple-of-4 length (processes well-formed prefix, ignores tail)")
        void nonMultipleOfFourLength() {
            // 7 elements: one complete tuple + 3 orphan weights.
            float[] w = { 0.5f, 0.3f, 0.1f, 0.0f, 0.9f, 0.9f, 0.9f };
            GlbParserUtils.normalizeWeights(w);
            assertEquals(1.0f, w[0] + w[1] + w[2] + w[3], 1.0e-5f);
            // Trailing elements should be untouched (not normalized).
            assertEquals(0.9f, w[4], 1.0e-6f);
            assertEquals(0.9f, w[5], 1.0e-6f);
            assertEquals(0.9f, w[6], 1.0e-6f);
        }

        @Test
        @DisplayName("null array is a safe no-op")
        void nullArrayIsSafe() {
            GlbParserUtils.normalizeWeights(null);
            // No exception = pass.
        }
    }

    @Nested
    @DisplayName("isPlayerMesh / stripArmaturePrefix")
    class NamingConventionTests {

        @Test
        @DisplayName("isPlayerMesh: exact name 'Player' matches")
        void isPlayerExact() {
            assertTrue(GlbParserUtils.isPlayerMesh("Player"));
        }

        @Test
        @DisplayName("isPlayerMesh: prefixed name 'Player_main' matches (startsWith)")
        void isPlayerPrefixed() {
            assertTrue(GlbParserUtils.isPlayerMesh("Player_main"));
            assertTrue(GlbParserUtils.isPlayerMesh("Player_left"));
            assertTrue(GlbParserUtils.isPlayerMesh("Player_foo"));
        }

        @Test
        @DisplayName("isPlayerMesh: non-Player name rejected")
        void isPlayerRejectsItem() {
            assertFalse(GlbParserUtils.isPlayerMesh("Item"));
            assertFalse(GlbParserUtils.isPlayerMesh(""));
            assertFalse(GlbParserUtils.isPlayerMesh("player")); // lowercase
            assertFalse(GlbParserUtils.isPlayerMesh("MyMesh"));
        }

        @Test
        @DisplayName("isPlayerMesh: null is safe and rejected")
        void isPlayerNullSafe() {
            assertFalse(GlbParserUtils.isPlayerMesh(null));
        }

        @Test
        @DisplayName("stripArmaturePrefix: 'MyRig|bone' → 'bone'")
        void stripsPipe() {
            assertEquals("bone", GlbParserUtils.stripArmaturePrefix("MyRig|bone"));
        }

        @Test
        @DisplayName("stripArmaturePrefix: name without pipe is unchanged")
        void noPipeUnchanged() {
            assertEquals("body", GlbParserUtils.stripArmaturePrefix("body"));
        }

        @Test
        @DisplayName("stripArmaturePrefix: multiple pipes → keep everything after LAST")
        void multiplePipesLastWins() {
            assertEquals("C", GlbParserUtils.stripArmaturePrefix("A|B|C"));
        }

        @Test
        @DisplayName("stripArmaturePrefix: prefix-only '|body' → 'body'")
        void prefixOnly() {
            assertEquals("body", GlbParserUtils.stripArmaturePrefix("|body"));
        }

        @Test
        @DisplayName("stripArmaturePrefix: trailing pipe 'body|' → empty string")
        void trailingPipe() {
            assertEquals("", GlbParserUtils.stripArmaturePrefix("body|"));
        }

        @Test
        @DisplayName("stripArmaturePrefix: null returns null")
        void nullReturnsNull() {
            assertNull(GlbParserUtils.stripArmaturePrefix(null));
        }
    }

    @Nested
    @DisplayName("readRestRotation / readRestTranslation")
    class ReadRestPoseTests {

        private JsonObject parseNode(String json) {
            return JsonParser.parseString(json).getAsJsonObject();
        }

        @Test
        @DisplayName("empty node returns identity quaternion")
        void emptyRotation() {
            Quaternionf q = GlbParserUtils.readRestRotation(parseNode("{}"));
            assertEquals(0.0f, q.x, 1.0e-6f);
            assertEquals(0.0f, q.y, 1.0e-6f);
            assertEquals(0.0f, q.z, 1.0e-6f);
            assertEquals(1.0f, q.w, 1.0e-6f);
        }

        @Test
        @DisplayName("empty node returns zero vector")
        void emptyTranslation() {
            Vector3f t = GlbParserUtils.readRestTranslation(parseNode("{}"));
            assertEquals(0.0f, t.x, 1.0e-6f);
            assertEquals(0.0f, t.y, 1.0e-6f);
            assertEquals(0.0f, t.z, 1.0e-6f);
        }

        @Test
        @DisplayName("node with rotation reads all four components in XYZW order")
        void readsRotation() {
            Quaternionf q = GlbParserUtils.readRestRotation(
                parseNode("{\"rotation\": [0.1, 0.2, 0.3, 0.9]}")
            );
            assertEquals(0.1f, q.x, 1.0e-6f);
            assertEquals(0.2f, q.y, 1.0e-6f);
            assertEquals(0.3f, q.z, 1.0e-6f);
            assertEquals(0.9f, q.w, 1.0e-6f);
        }

        @Test
        @DisplayName("node with translation reads all three components")
        void readsTranslation() {
            Vector3f t = GlbParserUtils.readRestTranslation(
                parseNode("{\"translation\": [1.5, -2.5, 3.0]}")
            );
            assertEquals(1.5f, t.x, 1.0e-6f);
            assertEquals(-2.5f, t.y, 1.0e-6f);
            assertEquals(3.0f, t.z, 1.0e-6f);
        }

        @Test
        @DisplayName("null node returns default values (no NPE)")
        void nullNode() {
            Quaternionf q = GlbParserUtils.readRestRotation(null);
            Vector3f t = GlbParserUtils.readRestTranslation(null);
            assertEquals(1.0f, q.w, 1.0e-6f);
            assertEquals(0.0f, t.x, 1.0e-6f);
        }
    }

    @Nested
    @DisplayName("buildParentJointIndices")
    class BuildParentJointIndicesTests {

        /** Helper to build a JsonArray of node-like JsonObjects. */
        private JsonArray parseNodes(String json) {
            return JsonParser.parseString(json).getAsJsonArray();
        }

        @Test
        @DisplayName("flat skeleton (no children) → all roots")
        void flatSkeleton() {
            JsonArray nodes = parseNodes("[{}, {}, {}]");
            int[] nodeToJoint = { 0, 1, 2 };
            int[] parents = GlbParserUtils.buildParentJointIndices(
                nodes,
                nodeToJoint,
                3
            );
            assertArrayEquals(new int[] { -1, -1, -1 }, parents);
        }

        @Test
        @DisplayName("simple tree: root with two children")
        void simpleTree() {
            // Node 0: root with children [1, 2]
            // Node 1: leaf
            // Node 2: leaf
            JsonArray nodes = parseNodes(
                "[{\"children\": [1, 2]}, {}, {}]"
            );
            int[] nodeToJoint = { 0, 1, 2 };
            int[] parents = GlbParserUtils.buildParentJointIndices(
                nodes,
                nodeToJoint,
                3
            );
            assertEquals(-1, parents[0]); // root
            assertEquals(0, parents[1]);
            assertEquals(0, parents[2]);
        }

        @Test
        @DisplayName("nested tree: root → child → grandchild")
        void nestedTree() {
            JsonArray nodes = parseNodes(
                "[{\"children\": [1]}, {\"children\": [2]}, {}]"
            );
            int[] nodeToJoint = { 0, 1, 2 };
            int[] parents = GlbParserUtils.buildParentJointIndices(
                nodes,
                nodeToJoint,
                3
            );
            assertArrayEquals(new int[] { -1, 0, 1 }, parents);
        }

        @Test
        @DisplayName("non-joint node in hierarchy is skipped")
        void nonJointSkipped() {
            // Nodes 0 is non-joint (-1 mapping), 1 and 2 are joints.
            // Node 0 children = [1, 2] but node 0 is not a joint → no parent set.
            JsonArray nodes = parseNodes(
                "[{\"children\": [1, 2]}, {}, {}]"
            );
            int[] nodeToJoint = { -1, 0, 1 };
            int[] parents = GlbParserUtils.buildParentJointIndices(
                nodes,
                nodeToJoint,
                2
            );
            assertArrayEquals(new int[] { -1, -1 }, parents);
        }
    }

    @Nested
    @DisplayName("parseAnimation")
    class ParseAnimationTests {

        /** Build a minimal in-memory GLB accessor layout: one FLOAT SCALAR, one VEC4. */
        private JsonObject parseJson(String s) {
            return JsonParser.parseString(s).getAsJsonObject();
        }

        @Test
        @DisplayName("animation with no matching channels returns null")
        void noMatchingChannels() {
            // Channel targets node 5, but nodeToJoint only covers 0-2.
            JsonObject anim = parseJson(
                "{" +
                "\"channels\": [{\"sampler\": 0, \"target\": {\"node\": 5, \"path\": \"rotation\"}}]," +
                "\"samplers\": [{\"input\": 0, \"output\": 1}]" +
                "}"
            );
            JsonArray accessors = parseJson(
                "{\"a\":[{\"count\":1,\"componentType\":5126,\"type\":\"SCALAR\",\"bufferView\":0}]}"
            ).getAsJsonArray("a");
            JsonArray bufferViews = parseJson(
                "{\"b\":[{\"byteLength\":4,\"byteOffset\":0}]}"
            ).getAsJsonArray("b");
            ByteBuffer bin = ByteBuffer.allocate(16).order(
                ByteOrder.LITTLE_ENDIAN
            );
            int[] nodeToJoint = { 0, 1, 2 };
            GltfData.AnimationClip clip = GlbParserUtils.parseAnimation(
                anim,
                accessors,
                bufferViews,
                bin,
                nodeToJoint,
                3
            );
            assertNull(
                clip,
                "no channels target the skin joints — should return null"
            );
        }

        @Test
        @DisplayName("single rotation channel produces per-joint rotations array")
        void singleRotationChannel() {
            // Layout:
            //   accessor 0: 1 SCALAR timestamp float  at byteOffset 0 (4 bytes)
            //   accessor 1: 1 VEC4 quat floats        at byteOffset 4 (16 bytes)
            // Channel: node 1 (jointIdx 1), path "rotation", sampler 0.
            JsonObject anim = parseJson(
                "{" +
                "\"channels\": [{\"sampler\": 0, \"target\": {\"node\": 1, \"path\": \"rotation\"}}]," +
                "\"samplers\": [{\"input\": 0, \"output\": 1}]" +
                "}"
            );
            JsonArray accessors = parseJson(
                "{\"a\":[" +
                "{\"count\":1,\"componentType\":5126,\"type\":\"SCALAR\",\"bufferView\":0,\"byteOffset\":0}," +
                "{\"count\":1,\"componentType\":5126,\"type\":\"VEC4\",\"bufferView\":1,\"byteOffset\":0}" +
                "]}"
            ).getAsJsonArray("a");
            JsonArray bufferViews = parseJson(
                "{\"b\":[" +
                "{\"byteLength\":4,\"byteOffset\":0}," +
                "{\"byteLength\":16,\"byteOffset\":4}" +
                "]}"
            ).getAsJsonArray("b");
            ByteBuffer bin = ByteBuffer.allocate(20).order(
                ByteOrder.LITTLE_ENDIAN
            );
            bin.putFloat(0, 0.0f); // timestamp 0
            bin.putFloat(4, 0.1f); // quat x
            bin.putFloat(8, 0.2f); // quat y
            bin.putFloat(12, 0.3f); // quat z
            bin.putFloat(16, 0.9f); // quat w
            int[] nodeToJoint = { -1, 1, -1 }; // only node 1 → joint 1

            GltfData.AnimationClip clip = GlbParserUtils.parseAnimation(
                anim,
                accessors,
                bufferViews,
                bin,
                nodeToJoint,
                2
            );
            assertNotNull(clip);
            assertEquals(1, clip.frameCount());
            assertArrayEquals(new float[] { 0.0f }, clip.timestamps(), 1.0e-6f);

            Quaternionf[][] rots = clip.rotations();
            assertEquals(2, rots.length);
            assertNull(rots[0], "joint 0 has no rotation channel");
            assertNotNull(rots[1]);
            assertEquals(0.1f, rots[1][0].x, 1.0e-6f);
            assertEquals(0.9f, rots[1][0].w, 1.0e-6f);
        }
    }

    @Nested
    @DisplayName("convertMeshToMinecraftSpace")
    class ConvertMeshTests {

        @Test
        @DisplayName("negates X and Y on positions, normals, rest translations")
        void negatesSpatialData() {
            float[] positions = { 1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f };
            float[] normals = { 0.1f, 0.2f, 0.3f };
            Vector3f[] restT = { new Vector3f(7.0f, 8.0f, 9.0f) };
            Quaternionf[] restR = { new Quaternionf(0.1f, 0.2f, 0.3f, 0.9f) };
            Matrix4f[] ibm = { new Matrix4f().identity() };

            GlbParserUtils.convertMeshToMinecraftSpace(
                positions,
                normals,
                restT,
                restR,
                ibm
            );

            assertArrayEquals(
                new float[] { -1.0f, -2.0f, 3.0f, -4.0f, -5.0f, 6.0f },
                positions,
                1.0e-6f
            );
            assertArrayEquals(
                new float[] { -0.1f, -0.2f, 0.3f },
                normals,
                1.0e-6f
            );
            assertEquals(-7.0f, restT[0].x, 1.0e-6f);
            assertEquals(-8.0f, restT[0].y, 1.0e-6f);
            assertEquals(9.0f, restT[0].z, 1.0e-6f);
            assertEquals(-0.1f, restR[0].x, 1.0e-6f);
            assertEquals(-0.2f, restR[0].y, 1.0e-6f);
            assertEquals(0.3f, restR[0].z, 1.0e-6f);
            assertEquals(0.9f, restR[0].w, 1.0e-6f);
        }

        @Test
        @DisplayName("IBM transform C·M·C inverts itself: applying twice returns original")
        void ibmDoubleInvertsToIdentity() {
            Matrix4f orig = new Matrix4f().rotateY(0.5f).translate(1f, 2f, 3f);
            Matrix4f ibm = new Matrix4f(orig);

            GlbParserUtils.convertMeshToMinecraftSpace(
                new float[0],
                new float[0],
                new Vector3f[0],
                new Quaternionf[0],
                new Matrix4f[] { ibm }
            );
            GlbParserUtils.convertMeshToMinecraftSpace(
                new float[0],
                new float[0],
                new Vector3f[0],
                new Quaternionf[0],
                new Matrix4f[] { ibm }
            );

            // C·C = identity, so C·(C·M·C)·C = M
            float[] origVals = new float[16];
            float[] ibmVals = new float[16];
            orig.get(origVals);
            ibm.get(ibmVals);
            assertArrayEquals(origVals, ibmVals, 1.0e-5f);
        }

        @Test
        @DisplayName("empty arrays are a safe no-op")
        void emptyArraysSafe() {
            GlbParserUtils.convertMeshToMinecraftSpace(
                new float[0],
                new float[0],
                new Vector3f[0],
                new Quaternionf[0],
                new Matrix4f[0]
            );
        }
    }

    @Nested
    @DisplayName("clampJointIndices")
    class ClampJointIndicesTests {

        @Test
        @DisplayName("clamps out-of-range positive indices to 0")
        void clampsPositiveOutOfRange() {
            int[] joints = { 0, 1, 999, 5 };
            int clamped = GlbParserUtils.clampJointIndices(joints, 10);
            assertArrayEquals(new int[] { 0, 1, 0, 5 }, joints);
            assertEquals(1, clamped);
        }

        @Test
        @DisplayName("clamps negative indices to 0")
        void clampsNegatives() {
            int[] joints = { 0, -5, 3, -1 };
            int clamped = GlbParserUtils.clampJointIndices(joints, 10);
            assertArrayEquals(new int[] { 0, 0, 3, 0 }, joints);
            assertEquals(2, clamped);
        }

        @Test
        @DisplayName("all-valid array: no clamps, returns 0")
        void allValidNoClamp() {
            int[] joints = { 0, 1, 2, 3 };
            int[] orig = joints.clone();
            int clamped = GlbParserUtils.clampJointIndices(joints, 10);
            assertArrayEquals(orig, joints);
            assertEquals(0, clamped);
        }

        @Test
        @DisplayName("null array is a safe no-op, returns 0")
        void nullArrayIsSafe() {
            int clamped = GlbParserUtils.clampJointIndices(null, 10);
            assertEquals(0, clamped);
        }

        @Test
        @DisplayName("jointCount boundary: index == jointCount is out-of-range")
        void boundaryEqualIsOutOfRange() {
            int[] joints = { 0, 9, 10 }; // 10 is OOB when jointCount=10
            int clamped = GlbParserUtils.clampJointIndices(joints, 10);
            assertArrayEquals(new int[] { 0, 9, 0 }, joints);
            assertEquals(1, clamped);
        }
    }
}
