package com.tiedup.remake.client.gltf;

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

/**
 * Parser for binary .glb (glTF 2.0) files.
 * Extracts mesh geometry, skinning data, bone hierarchy, and animations.
 * Filters out any mesh whose name starts with {@code "Player"} (the seat
 * armature convention) — see {@link GlbParserUtils#isPlayerMesh}.
 */
public final class GlbParser {

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

    private GlbParser() {}

    /**
     * Parse a .glb file from an InputStream. Validates header, version, and total
     * length (capped at {@link GlbParserUtils#MAX_GLB_SIZE}) before allocating chunk
     * buffers.
     *
     * @param input     the input stream (will be fully read)
     * @param debugName name for log messages
     * @return parsed GltfData
     * @throws IOException if the file is malformed, oversized, or truncated
     */
    public static GltfData 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");

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

        // Accept all skin joints (no filtering — custom bones are supported)
        List<Integer> allJointNodes = 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);
            // Log info for non-MC bones
            if (!GltfBoneMapper.isKnownBone(name)) {
                String suggestion = GltfBoneMapper.suggestBoneName(name);
                if (suggestion != null) {
                    LOGGER.warn(
                        "[GltfPipeline] Unknown bone '{}' in {} — did you mean '{}'? (treated as custom bone)",
                        name, debugName, suggestion
                    );
                } else {
                    LOGGER.info(
                        "[GltfPipeline] Custom bone '{}' in {} (will follow parent hierarchy in rest pose)",
                        name, debugName
                    );
                }
            }
            allJointNodes.add(nodeIdx);
        }

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

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

        // Read joint names + rest pose
        for (int j = 0; j < jointCount; j++) {
            int nodeIdx = allJointNodes.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(); // identity
            }
        }

        // -- Find mesh by convention, then fallback --
        // Priority: 1) mesh named "Item", 2) last non-Player mesh
        int targetMeshIdx = -1;
        String selectedMeshName = null;
        int nonPlayerCount = 0;
        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 ("Item".equals(meshName)) {
                    targetMeshIdx = mi;
                    selectedMeshName = meshName;
                    break; // Convention match — use it
                }
                if (!GlbParserUtils.isPlayerMesh(meshName)) {
                    targetMeshIdx = mi;
                    selectedMeshName = meshName;
                    nonPlayerCount++;
                }
            }
            if (nonPlayerCount > 1 && !"Item".equals(selectedMeshName)) {
                LOGGER.warn(
                    "[GltfPipeline] {} non-Player meshes found in {} — using '{}'. "
                        + "Name your mesh 'Item' for explicit selection.",
                    nonPlayerCount, debugName,
                    selectedMeshName != null ? selectedMeshName : "(unnamed)"
                );
            }
        }

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

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

        if (targetMeshIdx >= 0) {
            JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject();
            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 {
            // Animation-only GLB: no mesh data
            LOGGER.info(
                "[GltfPipeline] No mesh found in '{}' (animation-only GLB)",
                debugName
            );
            positions = new float[0];
            normals = new float[0];
            texCoords = new float[0];
            indices = new int[0];
            vertexCount = 0;
            meshJoints = new int[0];
            weights = new float[0];
        }

        // -- Read ALL animations --
        Map<String, GltfData.AnimationClip> allClips = new LinkedHashMap<>();
        JsonArray animations = root.getAsJsonArray("animations");
        if (animations != null) {
            for (int ai = 0; ai < animations.size(); ai++) {
                JsonObject anim = animations.get(ai).getAsJsonObject();
                String animName = anim.has("name")
                    ? anim.get("name").getAsString()
                    : "animation_" + ai;
                animName = GlbParserUtils.stripArmaturePrefix(animName);
                GltfData.AnimationClip clip = GlbParserUtils.parseAnimation(
                    anim,
                    accessors,
                    bufferViews,
                    binData,
                    nodeToJoint,
                    jointCount
                );
                if (clip != null) {
                    allClips.put(animName, clip);
                }
            }
        }

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

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

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

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

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

        // Convert from glTF coordinate system (Y-up, faces +Z) to MC (Y-up, faces -Z).
        // 180° rotation around Z: negate X and Y for all spatial data.
        for (GltfData.AnimationClip clip : allClips.values()) {
            GlbParserUtils.convertAnimationToMinecraftSpace(clip, jointCount);
        }
        GlbParserUtils.convertMeshToMinecraftSpace(
            positions,
            normals,
            restTranslations,
            restRotations,
            inverseBindMatrices
        );
        LOGGER.debug(
            "[GltfPipeline] Converted all data to Minecraft coordinate space"
        );

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

}
