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.LinkedHashMap;
import java.util.List;
import java.util.Map;
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 FurnitureGlbParser() {}

    /**
     * Parse a multi-armature .glb file into a {@link FurnitureGltfData}. Validates
     * header, version, and total length (capped at {@link GlbParserUtils#MAX_GLB_SIZE})
     * before allocating chunk buffers.
     *
     * @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, oversized, or truncated
     */
    public static FurnitureGltfData parse(InputStream input, String debugName)
        throws IOException {
        ByteBuffer buf = GlbParserUtils.readGlbSafely(input, debugName);

        // ---- JSON chunk ----
        int jsonChunkLength = GlbParserUtils.readChunkLength(
            buf,
            "JSON",
            debugName
        );
        int jsonChunkType = buf.getInt();
        if (jsonChunkType != GlbParserUtils.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 = GlbParserUtils.readChunkLength(
                buf,
                "BIN",
                debugName
            );
            int binChunkType = buf.getInt();
            if (binChunkType != GlbParserUtils.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 roots, classify skins, extract seat transforms ----
        // Delegated to PlayerArmatureScanner (extracted 2026-04-18) to keep this
        // orchestration method small. Behavior preserved byte-for-byte.
        PlayerArmatureScanner.ArmatureScan scan =
            PlayerArmatureScanner.scan(nodes);
        Map<String, Integer> seatIdToRootNode = scan.seatIdToRootNode;

        PlayerArmatureScanner.SkinClassification classification =
            PlayerArmatureScanner.classifySkins(
                skins,
                nodes,
                scan,
                debugName
            );
        int furnitureSkinIdx = classification.furnitureSkinIdx;
        Map<String, Integer> seatIdToSkinIdx = classification.seatIdToSkinIdx;

        Map<String, FurnitureGltfData.SeatTransform> seatTransforms =
            PlayerArmatureScanner.extractTransforms(nodes, seatIdToRootNode);

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

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

        // Strip the Blender armature prefix (e.g. "Armature|root" → "root")
        // so names line up with parseSeatSkeleton and GlbParser.
        for (int j = 0; j < jointCount; j++) {
            int nodeIdx = jointNodeIndices.get(j);
            JsonObject node = nodes.get(nodeIdx).getAsJsonObject();

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

            restRotations[j] = GlbParserUtils.readRestRotation(node);
            restTranslations[j] = GlbParserUtils.readRestTranslation(node);
        }

        int[] parentJointIndices = GlbParserUtils.buildParentJointIndices(
            nodes,
            nodeToJoint,
            jointCount
        );

        // ---- 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 (!GlbParserUtils.isPlayerMesh(meshName)) {
                    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();
            GlbParserUtils.PrimitiveParseResult r =
                GlbParserUtils.parsePrimitives(
                    mesh,
                    accessors,
                    bufferViews,
                    binData,
                    jointCount,
                    /* readSkinning */true,
                    materialNames,
                    debugName
                );
            positions = r.positions;
            normals = r.normals;
            texCoords = r.texCoords;
            indices = r.indices;
            meshJoints = r.joints;
            weights = r.weights;
            vertexCount = r.vertexCount;
            parsedPrimitives.addAll(r.primitives);
        } 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 ----
        GlbParserUtils.convertMeshToMinecraftSpace(
            positions,
            normals,
            restTranslations,
            restRotations,
            inverseBindMatrices
        );

        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 (!GlbParserUtils.isPlayerMesh(meshName)) {
                    targetMeshIdx = mi;
                    break;
                }
            }
        }

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

        if (targetMeshIdx >= 0) {
            JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject();
            GlbParserUtils.PrimitiveParseResult r =
                GlbParserUtils.parsePrimitives(
                    mesh,
                    accessors,
                    bufferViews,
                    binData,
                    /* jointCount */0,
                    /* readSkinning */false,
                    materialNames,
                    debugName
                );
            positions = r.positions;
            normals = r.normals;
            texCoords = r.texCoords;
            indices = r.indices;
            vertexCount = r.vertexCount;
            parsedPrimitives.addAll(r.primitives);

            // Mesh-only path does not run full convertMeshToMinecraftSpace
            // (no rest poses or IBMs to transform), just the vertex-space flip.
            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 rawName = node.has("name")
                ? node.get("name").getAsString()
                : "joint_" + j;
            String name = GlbParserUtils.stripArmaturePrefix(rawName);
            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];
        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
        for (int j = 0; j < jointCount; j++) {
            int nodeIdx = filteredJointNodes.get(j);
            JsonObject node = nodes.get(nodeIdx).getAsJsonObject();

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

            restRotations[j] = GlbParserUtils.readRestRotation(node);
            restTranslations[j] = GlbParserUtils.readRestTranslation(node);
        }

        int[] parentJointIndices = GlbParserUtils.buildParentJointIndices(
            nodes,
            nodeToJoint,
            jointCount
        );

        // ---- 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];
        GlbParserUtils.convertMeshToMinecraftSpace(
            emptyPositions,
            emptyNormals,
            restTranslations,
            restRotations,
            inverseBindMatrices
        );

        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 rawName = node.has("name")
                ? node.get("name").getAsString()
                : "joint_" + j;
            String name = GlbParserUtils.stripArmaturePrefix(rawName);
            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 = GlbParserUtils.parseAnimation(
                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 shared animation parsing logic
        return GlbParserUtils.parseAnimation(
            animation,
            accessors,
            bufferViews,
            binData,
            nodeToJoint,
            jointCount
        );
    }

}
