package com.tiedup.remake.v2.furniture.client;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.tiedup.remake.client.gltf.GlbParserUtils;
import com.tiedup.remake.client.gltf.GltfBoneMapper;
import com.tiedup.remake.client.gltf.GltfData;
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.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.Nullable;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;

/**
 * Parser for multi-armature .glb files used by the furniture system.
 *
 * <p>Unlike {@link com.tiedup.remake.client.gltf.GlbParser GlbParser} which assumes a single
 * skin/armature, this parser handles GLBs containing multiple armatures:
 * <ul>
 *   <li><b>Furniture armature</b> -- the furniture mesh and bones</li>
 *   <li><b>Player_* armatures</b> -- player skeletons that define seat positions</li>
 * </ul>
 *
 * <p>Armatures are identified by node name convention: nodes whose names start with
 * {@code "Player_"} are treated as seat armatures. The seat ID is extracted from the
 * suffix (e.g., {@code "Player_main"} yields seat ID {@code "main"}).
 *
 * <p>The furniture mesh is parsed fully (geometry, skinning, materials). For Player_*
 * armatures, the root transform (position + rotation) is extracted as a
 * {@link FurnitureGltfData.SeatTransform}, and the full player skeleton is parsed
 * with bone filtering via {@link GltfBoneMapper#isKnownBone(String)}.
 * Animations are partitioned by Blender prefix convention
 * ({@code "ArmatureName|AnimSuffix"}) and remapped to match filtered joint indices.
 *
 * <p>This class is client-only and must never be referenced from server code.
 */
@OnlyIn(Dist.CLIENT)
public final class FurnitureGlbParser {

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

    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 static final String PLAYER_PREFIX = "Player_";

    private FurnitureGlbParser() {}

    /**
     * Parse a multi-armature .glb file into a {@link FurnitureGltfData}.
     *
     * @param input     the input stream (read fully, not closed by this method)
     * @param debugName human-readable name for log messages
     * @return parsed furniture data
     * @throws IOException if the file is malformed or I/O fails
     */
    public static FurnitureGltfData parse(InputStream input, String debugName)
        throws IOException {
        byte[] allBytes = input.readAllBytes();
        ByteBuffer buf = ByteBuffer.wrap(allBytes).order(
            ByteOrder.LITTLE_ENDIAN
        );

        // ---- GLB 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
            );
        }
        buf.getInt(); // total file length — not needed, we already have all bytes

        // ---- 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");
        JsonArray skins = root.getAsJsonArray("skins");

        // ---- Identify Player_* armature root nodes ----
        // A "Player_*" armature root is any node whose name starts with "Player_".
        // Its name suffix is the seat ID.
        Map<String, Integer> seatIdToRootNode = new LinkedHashMap<>(); // seatId -> node index
        Set<Integer> playerRootNodes = new HashSet<>();

        if (nodes != null) {
            for (int ni = 0; ni < nodes.size(); ni++) {
                JsonObject node = nodes.get(ni).getAsJsonObject();
                String name = node.has("name")
                    ? node.get("name").getAsString()
                    : "";
                if (
                    name.startsWith(PLAYER_PREFIX) &&
                    name.length() > PLAYER_PREFIX.length()
                ) {
                    String seatId = name.substring(PLAYER_PREFIX.length());
                    seatIdToRootNode.put(seatId, ni);
                    playerRootNodes.add(ni);
                    LOGGER.debug(
                        "[FurnitureGltf] Found Player armature: '{}' -> seat '{}'",
                        name,
                        seatId
                    );
                }
            }
        }

        // ---- Classify skins ----
        // For each skin, check if its skeleton node (or the first joint's parent) is a Player_* root.
        // The "skeleton" field in a glTF skin points to the root bone node of that armature.
        int furnitureSkinIdx = -1;
        Map<String, Integer> seatIdToSkinIdx = new LinkedHashMap<>(); // seatId -> skin index

        if (skins != null) {
            for (int si = 0; si < skins.size(); si++) {
                JsonObject skin = skins.get(si).getAsJsonObject();
                int skeletonNode = skin.has("skeleton")
                    ? skin.get("skeleton").getAsInt()
                    : -1;

                // Check if the skeleton root node is a Player_* armature
                String matchedSeatId = null;
                if (
                    skeletonNode >= 0 && playerRootNodes.contains(skeletonNode)
                ) {
                    // Direct match: skeleton field points to a Player_* node
                    for (Map.Entry<
                        String,
                        Integer
                    > entry : seatIdToRootNode.entrySet()) {
                        if (entry.getValue() == skeletonNode) {
                            matchedSeatId = entry.getKey();
                            break;
                        }
                    }
                }

                // Fallback: check if any joint in this skin is a child of a Player_* root
                if (matchedSeatId == null && skin.has("joints")) {
                    matchedSeatId = matchSkinToPlayerArmature(
                        skin.getAsJsonArray("joints"),
                        nodes,
                        seatIdToRootNode
                    );
                }

                if (matchedSeatId != null) {
                    seatIdToSkinIdx.put(matchedSeatId, si);
                    LOGGER.debug(
                        "[FurnitureGltf] Skin {} -> seat '{}'",
                        si,
                        matchedSeatId
                    );
                } else if (furnitureSkinIdx < 0) {
                    furnitureSkinIdx = si;
                    LOGGER.debug("[FurnitureGltf] Skin {} -> furniture", si);
                } else {
                    LOGGER.warn(
                        "[FurnitureGltf] Extra non-Player skin {} ignored in '{}'",
                        si,
                        debugName
                    );
                }
            }
        }

        // ---- Extract seat transforms from Player_* root nodes ----
        Map<String, FurnitureGltfData.SeatTransform> seatTransforms =
            new LinkedHashMap<>();
        for (Map.Entry<String, Integer> entry : seatIdToRootNode.entrySet()) {
            String seatId = entry.getKey();
            int nodeIdx = entry.getValue();
            JsonObject node = nodes.get(nodeIdx).getAsJsonObject();

            Vector3f position = new Vector3f();
            if (node.has("translation")) {
                JsonArray t = node.getAsJsonArray("translation");
                position.set(
                    t.get(0).getAsFloat(),
                    t.get(1).getAsFloat(),
                    t.get(2).getAsFloat()
                );
            }

            Quaternionf rotation = new Quaternionf();
            if (node.has("rotation")) {
                JsonArray r = node.getAsJsonArray("rotation");
                rotation.set(
                    r.get(0).getAsFloat(),
                    r.get(1).getAsFloat(),
                    r.get(2).getAsFloat(),
                    r.get(3).getAsFloat()
                );
            }

            seatTransforms.put(
                seatId,
                new FurnitureGltfData.SeatTransform(seatId, position, rotation)
            );
            LOGGER.debug(
                "[FurnitureGltf] Seat '{}' transform: pos=({},{},{}), rot=({},{},{},{})",
                seatId,
                position.x,
                position.y,
                position.z,
                rotation.x,
                rotation.y,
                rotation.z,
                rotation.w
            );
        }

        // ---- Parse furniture mesh (full GltfData from the furniture skin) ----
        GltfData furnitureMesh = parseFurnitureSkin(
            furnitureSkinIdx,
            root,
            accessors,
            bufferViews,
            nodes,
            meshes,
            skins,
            binData,
            debugName
        );

        // ---- Partition animations by Blender prefix convention ----
        // Blender exports animations as "ArmatureName|AnimSuffix".
        // For Player_* armatures: key by seatId, strip to AnimSuffix.
        // For furniture armature: strip to AnimSuffix.
        Map<String, Map<String, GltfData.AnimationClip>> seatAnimations =
            new LinkedHashMap<>();
        Map<String, GltfData.AnimationClip> furnitureAnimClips =
            new LinkedHashMap<>();

        // Build set of known Player_* armature names for prefix matching
        Map<String, String> armatureNameToSeatId = new LinkedHashMap<>();
        for (Map.Entry<String, Integer> entry : seatIdToRootNode.entrySet()) {
            String seatId = entry.getKey();
            int nodeIdx = entry.getValue();
            JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
            String armatureName = node.has("name")
                ? node.get("name").getAsString()
                : "";
            armatureNameToSeatId.put(armatureName, seatId);
        }

        // Also detect the furniture armature name (for stripping its prefix)
        String furnitureArmatureName = detectFurnitureArmatureName(
            furnitureSkinIdx,
            skins,
            nodes
        );

        JsonArray animations = root.getAsJsonArray("animations");
        if (animations != null) {
            for (int ai = 0; ai < animations.size(); ai++) {
                JsonObject anim = animations.get(ai).getAsJsonObject();
                String rawName = anim.has("name")
                    ? anim.get("name").getAsString()
                    : "animation_" + ai;

                // Parse the "ArmatureName|AnimSuffix" convention
                String armaturePrefix = null;
                String animSuffix = rawName;
                if (rawName.contains("|")) {
                    int pipeIdx = rawName.lastIndexOf('|');
                    armaturePrefix = rawName.substring(0, pipeIdx);
                    animSuffix = rawName.substring(pipeIdx + 1);
                }

                // Determine which armature this animation belongs to
                String matchedSeatId = (armaturePrefix != null)
                    ? armatureNameToSeatId.get(armaturePrefix)
                    : null;

                if (matchedSeatId != null) {
                    // Player animation: parse with the seat's joint mapping
                    GltfData.AnimationClip clip = parseAnimationRaw(
                        anim,
                        accessors,
                        bufferViews,
                        binData,
                        nodes
                    );
                    if (clip != null) {
                        seatAnimations
                            .computeIfAbsent(matchedSeatId, k ->
                                new LinkedHashMap<>()
                            )
                            .put(animSuffix, clip);
                        LOGGER.debug(
                            "[FurnitureGltf] Seat '{}' animation: '{}'",
                            matchedSeatId,
                            animSuffix
                        );
                    }
                } else {
                    // Furniture animation (or unmatched prefix -- treat as furniture)
                    // Parse using the furniture joint mapping
                    GltfData.AnimationClip clip = parseFurnitureAnimation(
                        anim,
                        accessors,
                        bufferViews,
                        binData,
                        nodes,
                        furnitureSkinIdx,
                        skins
                    );
                    if (clip != null) {
                        furnitureAnimClips.put(animSuffix, clip);
                        LOGGER.debug(
                            "[FurnitureGltf] Furniture animation: '{}'",
                            animSuffix
                        );
                    }
                }
            }
        }

        // ---- Build furniture GltfData with animations injected ----
        // The furnitureMesh was parsed without animations; now rebuild it with the furniture clips.
        // If there are furniture animations, create a new GltfData with them.
        if (!furnitureAnimClips.isEmpty() && furnitureMesh != null) {
            GltfData.AnimationClip defaultClip = furnitureAnimClips
                .values()
                .iterator()
                .next();
            // Build raw copies for the raw-space maps
            Map<String, GltfData.AnimationClip> rawFurnitureClips =
                new LinkedHashMap<>();
            for (Map.Entry<
                String,
                GltfData.AnimationClip
            > e : furnitureAnimClips.entrySet()) {
                rawFurnitureClips.put(
                    e.getKey(),
                    GlbParserUtils.deepCopyClip(e.getValue())
                );
            }
            GltfData.AnimationClip rawDefaultClip = rawFurnitureClips
                .values()
                .iterator()
                .next();

            // Convert furniture animations to MC space
            for (GltfData.AnimationClip clip : furnitureAnimClips.values()) {
                GlbParserUtils.convertAnimationToMinecraftSpace(
                    clip,
                    furnitureMesh.jointCount()
                );
            }

            furnitureMesh = new GltfData(
                furnitureMesh.positions(),
                furnitureMesh.normals(),
                furnitureMesh.texCoords(),
                furnitureMesh.indices(),
                furnitureMesh.joints(),
                furnitureMesh.weights(),
                furnitureMesh.jointNames(),
                furnitureMesh.parentJointIndices(),
                furnitureMesh.inverseBindMatrices(),
                furnitureMesh.restRotations(),
                furnitureMesh.restTranslations(),
                furnitureMesh.rawGltfRestRotations(),
                rawDefaultClip,
                defaultClip,
                furnitureAnimClips,
                rawFurnitureClips,
                furnitureMesh.primitives(),
                furnitureMesh.vertexCount(),
                furnitureMesh.jointCount()
            );
        }

        // ---- Parse Player_* seat skeletons (V2: full skeleton for player animation) ----
        // For each Player_* skin, parse joints filtered through GltfBoneMapper.isKnownBone(),
        // build rest poses, inverse bind matrices, and bone hierarchy. Re-parse the seat
        // animations with the filtered joint mapping so clip indices match skeleton joints.
        Map<String, GltfData> seatSkeletons = new LinkedHashMap<>();

        for (Map.Entry<String, Integer> entry : seatIdToSkinIdx.entrySet()) {
            String seatId = entry.getKey();
            int skinIdx = entry.getValue();

            try {
                GltfData seatSkel = parseSeatSkeleton(
                    skinIdx,
                    root,
                    accessors,
                    bufferViews,
                    nodes,
                    skins,
                    binData,
                    seatId,
                    debugName
                );

                if (seatSkel != null && seatSkel.jointCount() > 0) {
                    // Re-parse this seat's animations with the skeleton's joint mapping
                    // so animation clip indices match the filtered skeleton joints.
                    Map<String, GltfData.AnimationClip> remappedClips =
                        remapSeatAnimations(
                            skinIdx,
                            skins,
                            nodes,
                            seatId,
                            armatureNameToSeatId,
                            animations,
                            accessors,
                            bufferViews,
                            binData
                        );

                    if (!remappedClips.isEmpty()) {
                        // Build raw copies before any conversion
                        Map<String, GltfData.AnimationClip> rawRemappedClips =
                            new LinkedHashMap<>();
                        for (Map.Entry<
                            String,
                            GltfData.AnimationClip
                        > clipEntry : remappedClips.entrySet()) {
                            rawRemappedClips.put(
                                clipEntry.getKey(),
                                GlbParserUtils.deepCopyClip(
                                    clipEntry.getValue()
                                )
                            );
                        }

                        // Rebuild the GltfData with the remapped animations as rawNamedAnimations
                        GltfData.AnimationClip defaultRawClip = rawRemappedClips
                            .values()
                            .iterator()
                            .next();
                        seatSkel = new GltfData(
                            seatSkel.positions(),
                            seatSkel.normals(),
                            seatSkel.texCoords(),
                            seatSkel.indices(),
                            seatSkel.joints(),
                            seatSkel.weights(),
                            seatSkel.jointNames(),
                            seatSkel.parentJointIndices(),
                            seatSkel.inverseBindMatrices(),
                            seatSkel.restRotations(),
                            seatSkel.restTranslations(),
                            seatSkel.rawGltfRestRotations(),
                            defaultRawClip,
                            null,
                            new LinkedHashMap<>(),
                            rawRemappedClips,
                            seatSkel.primitives(),
                            seatSkel.vertexCount(),
                            seatSkel.jointCount()
                        );

                        // Replace the raw-indexed clips in seatAnimations with properly-mapped ones
                        seatAnimations.put(seatId, remappedClips);
                    }

                    seatSkeletons.put(seatId, seatSkel);
                    LOGGER.debug(
                        "[FurnitureGltf] Parsed seat skeleton '{}': {} joints, {} animations",
                        seatId,
                        seatSkel.jointCount(),
                        remappedClips.size()
                    );
                } else {
                    LOGGER.warn(
                        "[FurnitureGltf] Seat '{}' skeleton has no known bones in '{}'",
                        seatId,
                        debugName
                    );
                }
            } catch (Exception e) {
                LOGGER.error(
                    "[FurnitureGltf] Failed to parse seat skeleton '{}' in '{}'",
                    seatId,
                    debugName,
                    e
                );
            }
        }

        LOGGER.info(
            "[FurnitureGltf] Parsed '{}': furnitureSkin={}, seats={}, seatSkeletons={}, furnitureAnims={}, seatAnimSets={}",
            debugName,
            furnitureSkinIdx >= 0 ? "yes" : "no",
            seatTransforms.size(),
            seatSkeletons.size(),
            furnitureAnimClips.size(),
            seatAnimations.size()
        );

        return new FurnitureGltfData(
            furnitureMesh,
            Collections.unmodifiableMap(seatTransforms),
            Collections.unmodifiableMap(seatAnimations),
            Collections.unmodifiableMap(seatSkeletons)
        );
    }

    // Skin classification helpers

    /**
     * Try to match a skin's joints to a Player_* armature by checking whether
     * any joint node is a descendant of a Player_* root node.
     */
    @Nullable
    private static String matchSkinToPlayerArmature(
        JsonArray skinJoints,
        JsonArray nodes,
        Map<String, Integer> seatIdToRootNode
    ) {
        for (JsonElement jointElem : skinJoints) {
            int jointNodeIdx = jointElem.getAsInt();
            for (Map.Entry<
                String,
                Integer
            > entry : seatIdToRootNode.entrySet()) {
                if (isDescendantOf(jointNodeIdx, entry.getValue(), nodes)) {
                    return entry.getKey();
                }
            }
        }
        return null;
    }

    /**
     * Check if nodeIdx is a descendant of ancestorIdx via the node children hierarchy.
     * Also returns true if nodeIdx == ancestorIdx.
     */
    private static boolean isDescendantOf(
        int nodeIdx,
        int ancestorIdx,
        JsonArray nodes
    ) {
        if (nodeIdx == ancestorIdx) return true;
        JsonObject ancestor = nodes.get(ancestorIdx).getAsJsonObject();
        if (!ancestor.has("children")) return false;
        for (JsonElement child : ancestor.getAsJsonArray("children")) {
            if (isDescendantOf(nodeIdx, child.getAsInt(), nodes)) return true;
        }
        return false;
    }

    /**
     * Detect the name of the furniture armature's skeleton root node.
     */
    @Nullable
    private static String detectFurnitureArmatureName(
        int furnitureSkinIdx,
        @Nullable JsonArray skins,
        JsonArray nodes
    ) {
        if (skins == null || furnitureSkinIdx < 0) return null;
        JsonObject skin = skins.get(furnitureSkinIdx).getAsJsonObject();
        if (!skin.has("skeleton")) return null;
        int skelNode = skin.get("skeleton").getAsInt();
        JsonObject node = nodes.get(skelNode).getAsJsonObject();
        return node.has("name") ? node.get("name").getAsString() : null;
    }

    // Furniture skin parsing (produces a GltfData)

    /**
     * Parse the furniture skin into a full {@link GltfData}.
     * This mirrors the logic in GlbParser but operates on a specific skin index
     * and does NOT filter bones via GltfBoneMapper (furniture has its own bone names).
     *
     * @return parsed GltfData, or a minimal empty GltfData if no furniture skin exists
     */
    private static GltfData parseFurnitureSkin(
        int skinIdx,
        JsonObject root,
        JsonArray accessors,
        JsonArray bufferViews,
        JsonArray nodes,
        @Nullable JsonArray meshes,
        @Nullable JsonArray skins,
        ByteBuffer binData,
        String debugName
    ) throws IOException {
        // If no furniture skin, return minimal GltfData (mesh-only GLB?)
        if (skins == null || skinIdx < 0) {
            LOGGER.warn(
                "[FurnitureGltf] No furniture skin found in '{}', attempting mesh-only parse",
                debugName
            );
            return buildMeshOnlyGltfData(
                root,
                accessors,
                bufferViews,
                nodes,
                meshes,
                binData,
                debugName
            );
        }

        JsonObject skin = skins.get(skinIdx).getAsJsonObject();
        JsonArray skinJoints = skin.getAsJsonArray("joints");

        // ---- Parse ALL joints from this skin (no bone filtering for furniture) ----
        int jointCount = skinJoints.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 for this skin
        int[] nodeToJoint = new int[nodes.size()];
        java.util.Arrays.fill(nodeToJoint, -1);

        List<Integer> jointNodeIndices = new ArrayList<>(jointCount);
        for (int j = 0; j < jointCount; j++) {
            int nodeIdx = skinJoints.get(j).getAsInt();
            jointNodeIndices.add(nodeIdx);
            nodeToJoint[nodeIdx] = j;
        }

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

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

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

            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];
                for (JsonElement child : node.getAsJsonArray("children")) {
                    int childNodeIdx = child.getAsInt();
                    if (childNodeIdx < nodeToJoint.length) {
                        int childJoint = nodeToJoint[childNodeIdx];
                        if (childJoint >= 0 && parentJoint >= 0) {
                            parentJointIndices[childJoint] = parentJoint;
                        }
                    }
                }
            }
        }

        // ---- Inverse bind matrices ----
        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 j = 0; j < jointCount; j++) {
                inverseBindMatrices[j] = new Matrix4f();
                inverseBindMatrices[j].set(ibmData, j * 16);
            }
        } else {
            for (int j = 0; j < jointCount; j++) {
                inverseBindMatrices[j] = new Matrix4f();
            }
        }

        // ---- Find the mesh associated with this skin ----
        // Walk nodes to find one that references this skin index AND has a mesh
        int targetMeshIdx = findMeshForSkin(skinIdx, nodes, meshes);

        // Fallback: take the last non-Player mesh (same heuristic as GlbParser)
        if (targetMeshIdx < 0 && 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 (!meshName.startsWith("Player")) {
                    targetMeshIdx = mi;
                }
            }
        }

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

        // ---- Parse mesh geometry ----
        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");

            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");

                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;

                int[] primIndices;
                if (primitive.has("indices")) {
                    primIndices = GlbParserUtils.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;
                    }
                }

                // Skinning (no remap needed -- furniture keeps all joints)
                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()
                    );
                }
                if (attributes.has("WEIGHTS_0")) {
                    primWeights = GlbParserUtils.readFloatAccessor(
                        accessors,
                        bufferViews,
                        binData,
                        attributes.get("WEIGHTS_0").getAsInt()
                    );
                }

                // Material / 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;
            }

            vertexCount = cumulativeVertexCount;
            positions = GlbParserUtils.flattenFloats(allPositions);
            normals = GlbParserUtils.flattenFloats(allNormals);
            texCoords = GlbParserUtils.flattenFloats(allTexCoords);
            meshJoints = GlbParserUtils.flattenInts(allJoints);
            weights = GlbParserUtils.flattenFloats(allWeights);

            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 {
            LOGGER.info(
                "[FurnitureGltf] No furniture mesh found in '{}'",
                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];
        }

        // ---- Save raw rest rotations before MC conversion ----
        Quaternionf[] rawRestRotations = new Quaternionf[jointCount];
        for (int j = 0; j < jointCount; j++) {
            rawRestRotations[j] = new Quaternionf(restRotations[j]);
        }

        // ---- Convert to Minecraft space ----
        convertToMinecraftSpace(
            positions,
            normals,
            restTranslations,
            restRotations,
            inverseBindMatrices,
            jointCount
        );

        return new GltfData(
            positions,
            normals,
            texCoords,
            indices,
            meshJoints,
            weights,
            jointNames,
            parentJointIndices,
            inverseBindMatrices,
            restRotations,
            restTranslations,
            rawRestRotations,
            null,
            null, // animations injected later by the caller
            new LinkedHashMap<>(),
            new LinkedHashMap<>(),
            parsedPrimitives,
            vertexCount,
            jointCount
        );
    }

    /**
     * Build a minimal GltfData for a mesh-only GLB (no skin/armature).
     * Used as fallback when the furniture has no skeleton.
     */
    private static GltfData buildMeshOnlyGltfData(
        JsonObject root,
        JsonArray accessors,
        JsonArray bufferViews,
        JsonArray nodes,
        @Nullable JsonArray meshes,
        ByteBuffer binData,
        String debugName
    ) {
        String[] materialNames = GlbParserUtils.parseMaterialNames(root);
        List<GltfData.Primitive> parsedPrimitives = new ArrayList<>();

        // Find the first non-Player mesh
        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 (!meshName.startsWith("Player")) {
                    targetMeshIdx = mi;
                    break;
                }
            }
        }

        float[] positions;
        float[] normals;
        float[] texCoords;
        int[] indices;
        int vertexCount;

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

            List<float[]> allPositions = new ArrayList<>();
            List<float[]> allNormals = new ArrayList<>();
            List<float[]> allTexCoords = new 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 = 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;

                int[] primIndices;
                if (primitive.has("indices")) {
                    primIndices = GlbParserUtils.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;
                    }
                }

                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_");
                parsedPrimitives.add(
                    new GltfData.Primitive(
                        primIndices,
                        matName,
                        isTintable,
                        isTintable ? matName : null
                    )
                );

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

            vertexCount = cumulativeVertexCount;
            positions = GlbParserUtils.flattenFloats(allPositions);
            normals = GlbParserUtils.flattenFloats(allNormals);
            texCoords = GlbParserUtils.flattenFloats(allTexCoords);

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

            // Convert positions and normals to MC space
            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];
            }
        } else {
            positions = new float[0];
            normals = new float[0];
            texCoords = new float[0];
            indices = new int[0];
            vertexCount = 0;
        }

        return new GltfData(
            positions,
            normals,
            texCoords,
            indices,
            new int[0],
            new float[0],
            new String[0],
            new int[0],
            new Matrix4f[0],
            new Quaternionf[0],
            new Vector3f[0],
            new Quaternionf[0],
            null,
            null,
            new LinkedHashMap<>(),
            new LinkedHashMap<>(),
            parsedPrimitives,
            vertexCount,
            0
        );
    }

    /**
     * Find the mesh index associated with a given skin index by scanning nodes.
     * A node that has both a "skin" field matching skinIdx and a "mesh" field
     * links the two.
     */
    private static int findMeshForSkin(
        int skinIdx,
        JsonArray nodes,
        @Nullable JsonArray meshes
    ) {
        if (meshes == null || skinIdx < 0) return -1;
        for (int ni = 0; ni < nodes.size(); ni++) {
            JsonObject node = nodes.get(ni).getAsJsonObject();
            if (
                node.has("skin") &&
                node.get("skin").getAsInt() == skinIdx &&
                node.has("mesh")
            ) {
                return node.get("mesh").getAsInt();
            }
        }
        return -1;
    }

    // Seat skeleton parsing (Player_* armatures for player animation)

    /**
     * Parse a Player_* skin into a skeleton-only {@link GltfData} for player animation.
     *
     * <p>Mirrors {@link #parseFurnitureSkin} but filters joints through
     * {@link GltfBoneMapper#isKnownBone(String)}, keeping only player-skeleton bones.
     * Mesh data is empty (we never render the player mesh from GLB data).
     * Animations are NOT included here; they are re-parsed separately with the
     * filtered joint mapping by {@link #remapSeatAnimations}.</p>
     *
     * @param skinIdx    the skin index for this Player_* armature
     * @param root       the root glTF JSON object
     * @param accessors  glTF accessors array
     * @param bufferViews glTF bufferViews array
     * @param nodes      glTF nodes array
     * @param skins      glTF skins array
     * @param binData    the binary buffer
     * @param seatId     seat identifier for logging
     * @param debugName  file name for logging
     * @return a skeleton-only GltfData with filtered player bones, or null on failure
     */
    @Nullable
    private static GltfData parseSeatSkeleton(
        int skinIdx,
        JsonObject root,
        JsonArray accessors,
        JsonArray bufferViews,
        JsonArray nodes,
        @Nullable JsonArray skins,
        ByteBuffer binData,
        String seatId,
        String debugName
    ) throws IOException {
        if (skins == null || skinIdx < 0) {
            LOGGER.warn(
                "[FurnitureGltf] No skin for seat '{}' in '{}'",
                seatId,
                debugName
            );
            return null;
        }

        JsonObject skin = skins.get(skinIdx).getAsJsonObject();
        JsonArray skinJoints = skin.getAsJsonArray("joints");

        // ---- Filter joints to known player bones (like GlbParser) ----
        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(
                    "[FurnitureGltf] Seat '{}': skipping non-player bone '{}'",
                    seatId,
                    name
                );
            }
        }

        int jointCount = filteredJointNodes.size();
        if (jointCount == 0) {
            LOGGER.warn(
                "[FurnitureGltf] Seat '{}' skin has no known player bones in '{}'",
                seatId,
                debugName
            );
            return null;
        }

        String[] jointNames = new String[jointCount];
        int[] parentJointIndices = new int[jointCount];
        Quaternionf[] restRotations = new Quaternionf[jointCount];
        Vector3f[] restTranslations = new Vector3f[jointCount];

        // Map node index -> filtered joint index
        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 and rest poses
        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;

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

            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];
                for (JsonElement child : node.getAsJsonArray("children")) {
                    int childNodeIdx = child.getAsInt();
                    if (childNodeIdx < nodeToJoint.length) {
                        int childJoint = nodeToJoint[childNodeIdx];
                        if (childJoint >= 0 && parentJoint >= 0) {
                            parentJointIndices[childJoint] = parentJoint;
                        }
                    }
                }
            }
        }

        // ---- Inverse bind matrices ----
        // IBM accessor is indexed by original skin joint order, pick 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();
            }
        }

        // ---- Save raw rest rotations BEFORE MC conversion ----
        Quaternionf[] rawRestRotations = new Quaternionf[jointCount];
        for (int j = 0; j < jointCount; j++) {
            rawRestRotations[j] = new Quaternionf(restRotations[j]);
        }

        // ---- Convert to Minecraft space ----
        // Empty arrays for positions/normals (skeleton-only, no mesh)
        float[] emptyPositions = new float[0];
        float[] emptyNormals = new float[0];
        convertToMinecraftSpace(
            emptyPositions,
            emptyNormals,
            restTranslations,
            restRotations,
            inverseBindMatrices,
            jointCount
        );

        LOGGER.debug(
            "[FurnitureGltf] Seat '{}' skeleton: {} filtered joints from {} total in '{}'",
            seatId,
            jointCount,
            skinJoints.size(),
            debugName
        );
        for (int j = 0; j < jointCount; j++) {
            LOGGER.debug(
                "[FurnitureGltf]   seat '{}' joint[{}] = '{}', parent={}",
                seatId,
                j,
                jointNames[j],
                parentJointIndices[j]
            );
        }

        // Return skeleton-only GltfData (no mesh, no animations yet)
        return new GltfData(
            new float[0],
            new float[0],
            new float[0], // positions, normals, texCoords
            new int[0],
            new int[0],
            new float[0], // indices, joints, weights
            jointNames,
            parentJointIndices,
            inverseBindMatrices,
            restRotations,
            restTranslations,
            rawRestRotations,
            null,
            null, // animations added later
            new LinkedHashMap<>(),
            new LinkedHashMap<>(),
            List.of(), // no primitives
            0,
            jointCount
        );
    }

    /**
     * Re-parse a seat's animations using the filtered joint mapping from the seat skeleton.
     *
     * <p>The original {@code seatAnimations} map contains clips parsed by
     * {@link #parseAnimationRaw} where indices correspond to raw node indices.
     * These need to be re-parsed with the seat skin's filtered {@code nodeToJoint}
     * mapping so clip rotation/translation arrays are indexed by filtered joint index,
     * matching the seat skeleton's joint ordering.</p>
     *
     * @param skinIdx             the skin index for this Player_* armature
     * @param skins               glTF skins array
     * @param nodes               glTF nodes array
     * @param seatId              seat identifier for matching animation prefixes
     * @param armatureNameToSeatId maps armature names to seat IDs
     * @param animations          the root glTF animations array (may be null)
     * @param accessors           glTF accessors array
     * @param bufferViews         glTF bufferViews array
     * @param binData             the binary buffer
     * @return map of animation name to properly-indexed clip, or empty map if none found
     */
    private static Map<String, GltfData.AnimationClip> remapSeatAnimations(
        int skinIdx,
        @Nullable JsonArray skins,
        JsonArray nodes,
        String seatId,
        Map<String, String> armatureNameToSeatId,
        @Nullable JsonArray animations,
        JsonArray accessors,
        JsonArray bufferViews,
        ByteBuffer binData
    ) {
        Map<String, GltfData.AnimationClip> result = new LinkedHashMap<>();

        if (animations == null || skins == null || skinIdx < 0) return result;

        // Build the filtered nodeToJoint mapping for this seat's skin
        JsonObject skin = skins.get(skinIdx).getAsJsonObject();
        JsonArray skinJoints = skin.getAsJsonArray("joints");

        List<Integer> filteredJointNodes = new ArrayList<>();
        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)) {
                filteredJointNodes.add(nodeIdx);
            }
        }

        int jointCount = filteredJointNodes.size();
        if (jointCount == 0) return result;

        int[] nodeToJoint = new int[nodes.size()];
        java.util.Arrays.fill(nodeToJoint, -1);
        for (int j = 0; j < jointCount; j++) {
            nodeToJoint[filteredJointNodes.get(j)] = j;
        }

        // Find this seat's armature name for prefix matching
        String targetArmatureName = null;
        for (Map.Entry<
            String,
            String
        > entry : armatureNameToSeatId.entrySet()) {
            if (seatId.equals(entry.getValue())) {
                targetArmatureName = entry.getKey();
                break;
            }
        }

        // Re-parse each animation that belongs to this seat
        for (int ai = 0; ai < animations.size(); ai++) {
            JsonObject anim = animations.get(ai).getAsJsonObject();
            String rawName = anim.has("name")
                ? anim.get("name").getAsString()
                : "animation_" + ai;

            // Parse "ArmatureName|AnimSuffix" convention
            String armaturePrefix = null;
            String animSuffix = rawName;
            if (rawName.contains("|")) {
                int pipeIdx = rawName.lastIndexOf('|');
                armaturePrefix = rawName.substring(0, pipeIdx);
                animSuffix = rawName.substring(pipeIdx + 1);
            }

            // Only process animations belonging to this seat's armature
            if (
                armaturePrefix == null ||
                !armaturePrefix.equals(targetArmatureName)
            ) {
                continue;
            }

            // Parse with the filtered joint mapping
            GltfData.AnimationClip clip = parseAnimationWithMapping(
                anim,
                accessors,
                bufferViews,
                binData,
                nodeToJoint,
                jointCount
            );
            if (clip != null) {
                result.put(animSuffix, clip);
                LOGGER.debug(
                    "[FurnitureGltf] Remapped seat '{}' animation: '{}' ({} joints)",
                    seatId,
                    animSuffix,
                    jointCount
                );
            }
        }

        return result;
    }

    // Animation parsing

    /**
     * Parse a raw animation clip without any joint index mapping.
     * Used for seat (Player_*) animations where we store them in raw glTF space
     * for later conversion by GltfPoseConverter.
     *
     * <p>Returns an AnimationClip where joint indices correspond to animation channel
     * target node indices (NOT skin joint indices). The clip is stored as-is in the
     * seatAnimations map for downstream processing.
     *
     * @return the parsed clip, or null if no rotation/translation channels found
     */
    @Nullable
    private static GltfData.AnimationClip parseAnimationRaw(
        JsonObject animation,
        JsonArray accessors,
        JsonArray bufferViews,
        ByteBuffer binData,
        JsonArray nodes
    ) {
        JsonArray channels = animation.getAsJsonArray("channels");
        JsonArray samplers = animation.getAsJsonArray("samplers");

        // Determine the max node index referenced by this animation's channels
        int maxNodeIdx = 0;
        for (JsonElement chElem : channels) {
            JsonObject channel = chElem.getAsJsonObject();
            JsonObject target = channel.getAsJsonObject("target");
            if (target.has("node")) {
                maxNodeIdx = Math.max(
                    maxNodeIdx,
                    target.get("node").getAsInt()
                );
            }
        }
        int slotCount = maxNodeIdx + 1;

        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");
            if (!target.has("node")) continue;
            String path = target.get("path").getAsString();
            int nodeIdx = target.get("node").getAsInt();

            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(nodeIdx);
                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(nodeIdx);
                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[slotCount][];
        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[slotCount][];
        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
        );
    }

    /**
     * Parse a furniture animation clip using the furniture skin's joint mapping.
     * Channels targeting nodes outside the furniture skin are ignored.
     */
    @Nullable
    private static GltfData.AnimationClip parseFurnitureAnimation(
        JsonObject animation,
        JsonArray accessors,
        JsonArray bufferViews,
        ByteBuffer binData,
        JsonArray nodes,
        int furnitureSkinIdx,
        @Nullable JsonArray skins
    ) {
        if (skins == null || furnitureSkinIdx < 0) return null;

        JsonObject skin = skins.get(furnitureSkinIdx).getAsJsonObject();
        JsonArray skinJoints = skin.getAsJsonArray("joints");
        int jointCount = skinJoints.size();

        // Build nodeToJoint mapping for this skin
        int[] nodeToJoint = new int[nodes.size()];
        java.util.Arrays.fill(nodeToJoint, -1);
        for (int j = 0; j < jointCount; j++) {
            int nodeIdx = skinJoints.get(j).getAsInt();
            nodeToJoint[nodeIdx] = j;
        }

        // Delegate to the standard animation parsing logic
        return parseAnimationWithMapping(
            animation,
            accessors,
            bufferViews,
            binData,
            nodeToJoint,
            jointCount
        );
    }

    /**
     * Parse an animation clip using a provided node-to-joint index mapping.
     * Mirrors GlbParser.parseAnimation exactly.
     */
    @Nullable
    private static GltfData.AnimationClip parseAnimationWithMapping(
        JsonObject animation,
        JsonArray accessors,
        JsonArray bufferViews,
        ByteBuffer binData,
        int[] nodeToJoint,
        int jointCount
    ) {
        JsonArray channels = animation.getAsJsonArray("channels");
        JsonArray samplers = animation.getAsJsonArray("samplers");

        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");
            if (!target.has("node")) continue;
            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;

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

    // Coordinate conversion

    /**
     * Convert spatial data from glTF space to Minecraft model-def space.
     * Same transform as GlbParser: 180 degrees around Z, negating X and Y.
     */
    private static void convertToMinecraftSpace(
        float[] positions,
        float[] normals,
        Vector3f[] restTranslations,
        Quaternionf[] restRotations,
        Matrix4f[] inverseBindMatrices,
        int jointCount
    ) {
        // Vertex positions: negate X and Y
        for (int i = 0; i < positions.length; i += 3) {
            positions[i] = -positions[i];
            positions[i + 1] = -positions[i + 1];
        }
        // 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 deg 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);
        }
    }
}
