package com.tiedup.remake.client.gltf.diagnostic;

import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.tiedup.remake.client.gltf.GltfBoneMapper;
import com.tiedup.remake.client.gltf.diagnostic.GlbDiagnostic.Severity;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;

/**
 * Lightweight structural validator for GLB files.
 *
 * <p>Reads only the 12-byte header and the JSON chunk (first chunk) — never
 * touches the binary mesh data. Produces a list of {@link GlbDiagnostic}
 * findings that can range from hard errors (invalid header, missing skin) to
 * informational notes (custom bone names).</p>
 *
 * <p>All methods are static; the class cannot be instantiated.</p>
 */
@OnlyIn(Dist.CLIENT)
public final class GlbValidator {

    // GLB binary format constants are shared with the runtime parsers to
    // prevent divergence. See GlbParserUtils for the canonical definitions.
    private static final int GLB_MAGIC =
        com.tiedup.remake.client.gltf.GlbParserUtils.GLB_MAGIC;
    private static final int GLB_VERSION =
        com.tiedup.remake.client.gltf.GlbParserUtils.GLB_VERSION;
    private static final int CHUNK_JSON =
        com.tiedup.remake.client.gltf.GlbParserUtils.CHUNK_JSON;

    private GlbValidator() {}

    /**
     * Validate a GLB file by reading its header and JSON chunk.
     *
     * @param input  the raw GLB byte stream (will be fully consumed)
     * @param source resource location of the GLB file (used in diagnostics)
     * @return a validation result containing all findings
     */
    public static GlbValidationResult validate(
        InputStream input,
        ResourceLocation source
    ) {
        List<GlbDiagnostic> diagnostics = new ArrayList<>();

        JsonObject root;
        try {
            root = readJsonChunk(input, source, diagnostics);
        } catch (Exception e) {
            diagnostics.add(new GlbDiagnostic(
                source, null, Severity.ERROR, "INVALID_GLB_HEADER",
                "Failed to read GLB header or JSON chunk: " + e.getMessage()
            ));
            return GlbValidationResult.of(source, diagnostics);
        }

        // If readJsonChunk added an ERROR, root will be null
        if (root == null) {
            return GlbValidationResult.of(source, diagnostics);
        }

        validateSkins(root, source, diagnostics);
        validateMeshes(root, source, diagnostics);
        validateAnimations(root, source, diagnostics);

        return GlbValidationResult.of(source, diagnostics);
    }

    // ------------------------------------------------------------------ //
    //  Header + JSON chunk extraction                                      //
    // ------------------------------------------------------------------ //

    /** Maximum GLB file size the validator will accept (shared with runtime parsers). */
    private static final long MAX_GLB_SIZE =
        com.tiedup.remake.client.gltf.GlbParserUtils.MAX_GLB_SIZE;

    /**
     * Parse the GLB header and extract the JSON chunk root object.
     * Returns null and adds ERROR diagnostics on failure.
     */
    private static JsonObject readJsonChunk(
        InputStream input,
        ResourceLocation source,
        List<GlbDiagnostic> diagnostics
    ) throws Exception {
        // Read the 12-byte header first to check totalLength before
        // committing to readAllBytes (OOM guard for malformed GLBs).
        byte[] header = input.readNBytes(12);
        if (header.length < 12) {
            diagnostics.add(new GlbDiagnostic(
                source, null, Severity.ERROR, "INVALID_GLB_HEADER",
                "File too small to contain a GLB header (" + header.length + " bytes)"
            ));
            return null;
        }

        ByteBuffer headerBuf = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN);

        // -- Header (12 bytes) --
        int magic = headerBuf.getInt();
        if (magic != GLB_MAGIC) {
            diagnostics.add(new GlbDiagnostic(
                source, null, Severity.ERROR, "INVALID_GLB_HEADER",
                String.format("Bad magic number: 0x%08X (expected 0x%08X)", magic, GLB_MAGIC)
            ));
            return null;
        }

        int version = headerBuf.getInt();
        if (version != GLB_VERSION) {
            diagnostics.add(new GlbDiagnostic(
                source, null, Severity.ERROR, "INVALID_GLB_HEADER",
                "Unsupported GLB version " + version + " (expected " + GLB_VERSION + ")"
            ));
            return null;
        }

        int totalLength = headerBuf.getInt();

        // OOM guard: reject files that declare a size exceeding the cap.
        // totalLength is a signed int, so treat negative values as > 2 GB.
        if (totalLength < 0 || Integer.toUnsignedLong(totalLength) > MAX_GLB_SIZE) {
            diagnostics.add(new GlbDiagnostic(
                source, null, Severity.ERROR, "GLB_TOO_LARGE",
                "GLB declares totalLength=" + Integer.toUnsignedLong(totalLength)
                    + " bytes which exceeds the " + (MAX_GLB_SIZE / (1024 * 1024))
                    + " MB safety cap — aborting validation"
            ));
            return null;
        }

        // Now read the remainder (totalLength includes the 12-byte header)
        int remaining = totalLength - 12;
        if (remaining < 0) {
            diagnostics.add(new GlbDiagnostic(
                source, null, Severity.ERROR, "INVALID_GLB_HEADER",
                "GLB totalLength (" + totalLength + ") is smaller than the header itself"
            ));
            return null;
        }
        byte[] restBytes = input.readNBytes(remaining);
        ByteBuffer buf = ByteBuffer.wrap(restBytes).order(ByteOrder.LITTLE_ENDIAN);

        // -- First chunk must be JSON --
        if (buf.remaining() < 8) {
            diagnostics.add(new GlbDiagnostic(
                source, null, Severity.ERROR, "INVALID_GLB_HEADER",
                "File truncated: no chunk header after GLB header"
            ));
            return null;
        }

        int jsonChunkLength = buf.getInt();
        int jsonChunkType = buf.getInt();

        if (jsonChunkType != CHUNK_JSON) {
            diagnostics.add(new GlbDiagnostic(
                source, null, Severity.ERROR, "INVALID_GLB_HEADER",
                String.format(
                    "First chunk is not JSON: type 0x%08X (expected 0x%08X)",
                    jsonChunkType, CHUNK_JSON
                )
            ));
            return null;
        }

        if (buf.remaining() < jsonChunkLength) {
            diagnostics.add(new GlbDiagnostic(
                source, null, Severity.ERROR, "INVALID_GLB_HEADER",
                "File truncated: JSON chunk declares " + jsonChunkLength
                    + " bytes but only " + buf.remaining() + " remain"
            ));
            return null;
        }

        byte[] jsonBytes = new byte[jsonChunkLength];
        buf.get(jsonBytes);
        String jsonStr = new String(jsonBytes, StandardCharsets.UTF_8);

        return JsonParser.parseString(jsonStr).getAsJsonObject();
    }

    // ------------------------------------------------------------------ //
    //  Skin / bone validation                                              //
    // ------------------------------------------------------------------ //

    private static void validateSkins(
        JsonObject root,
        ResourceLocation source,
        List<GlbDiagnostic> diagnostics
    ) {
        if (!root.has("skins")) {
            diagnostics.add(new GlbDiagnostic(
                source, null, Severity.ERROR, "NO_SKINS",
                "GLB has no 'skins' array — skinned mesh rendering requires at least one skin"
            ));
            return;
        }

        JsonArray skins = root.getAsJsonArray("skins");
        if (skins.size() == 0) {
            diagnostics.add(new GlbDiagnostic(
                source, null, Severity.ERROR, "NO_SKINS",
                "GLB 'skins' array is empty — skinned mesh rendering requires at least one skin"
            ));
            return;
        }

        JsonArray nodes = root.has("nodes") ? root.getAsJsonArray("nodes") : null;
        if (nodes == null) return;

        // Iterate every skin. Furniture GLBs contain multiple (one per
        // Player_* seat armature + one for the mesh itself); validating
        // only skin 0 let a broken seat skin crash at load time after
        // passing validation.
        for (int si = 0; si < skins.size(); si++) {
            validateSkin(skins.get(si).getAsJsonObject(), si, root, nodes, source, diagnostics);
        }
    }

    private static void validateSkin(
        JsonObject skin,
        int skinIndex,
        JsonObject root,
        JsonArray nodes,
        ResourceLocation source,
        List<GlbDiagnostic> diagnostics
    ) {
        if (!skin.has("joints")) return;

        JsonArray joints = skin.getAsJsonArray("joints");
        String skinLabel = "skin " + skinIndex;

        for (int j = 0; j < joints.size(); j++) {
            int nodeIdx = joints.get(j).getAsInt();
            if (nodeIdx < 0 || nodeIdx >= nodes.size()) {
                continue;
            }

            JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
            if (!node.has("name")) {
                continue;
            }

            String rawName = node.get("name").getAsString();
            String boneName = com.tiedup.remake.client.gltf.GlbParserUtils.stripArmaturePrefix(
                rawName
            );

            if (GltfBoneMapper.isKnownBone(boneName)) continue;

            String suggestion = GltfBoneMapper.suggestBoneName(boneName);
            if (suggestion != null) {
                diagnostics.add(new GlbDiagnostic(
                    source, null, Severity.WARNING, "BONE_TYPO_SUGGESTION",
                    skinLabel + ": bone '" + boneName + "' is not recognized — did you mean '"
                        + suggestion + "'?"
                ));
            } else {
                diagnostics.add(new GlbDiagnostic(
                    source, null, Severity.INFO, "UNKNOWN_BONE",
                    skinLabel + ": bone '" + boneName + "' is not a standard MC bone (treated as custom bone)"
                ));
            }
        }

        // IBM accessor element count + type checks. A short accessor throws
        // AIOOBE when GlbParser builds per-joint matrices; an over-long one
        // wastes memory. Missing IBM entirely: glTF spec substitutes identity,
        // almost always an authoring bug (renders in bind pose at origin).
        if (!skin.has("inverseBindMatrices")) {
            // Zero-joint skins already trip earlier diagnostics; the
            // "0 joints but no IBM" warning adds nothing.
            if (joints.size() > 0) {
                diagnostics.add(new GlbDiagnostic(
                    source, null, Severity.WARNING, "IBM_MISSING",
                    skinLabel + " has " + joints.size() + " joints but no inverseBindMatrices " +
                    "— runtime will substitute identity, mesh will render in bind pose at origin"
                ));
            }
        } else if (root.has("accessors")) {
            int ibmAccIdx = skin.get("inverseBindMatrices").getAsInt();
            JsonArray accessors = root.getAsJsonArray("accessors");
            if (ibmAccIdx >= 0 && ibmAccIdx < accessors.size()) {
                JsonObject ibmAcc = accessors
                    .get(ibmAccIdx)
                    .getAsJsonObject();
                int ibmCount = ibmAcc.has("count")
                    ? ibmAcc.get("count").getAsInt()
                    : -1;
                String ibmType = ibmAcc.has("type")
                    ? ibmAcc.get("type").getAsString()
                    : "";
                if (!ibmAcc.has("type")) {
                    diagnostics.add(new GlbDiagnostic(
                        source, null, Severity.ERROR, "IBM_MISSING_TYPE",
                        skinLabel + " inverseBindMatrices accessor has no 'type' field — " +
                        "re-export with the skin's armature selected"
                    ));
                } else if (!"MAT4".equals(ibmType)) {
                    diagnostics.add(new GlbDiagnostic(
                        source, null, Severity.ERROR, "IBM_WRONG_TYPE",
                        skinLabel + " inverseBindMatrices type is '" + ibmType +
                        "', expected MAT4 — re-export with the skin's armature selected"
                    ));
                }
                if (ibmCount >= 0 && ibmCount != joints.size()) {
                    diagnostics.add(new GlbDiagnostic(
                        source, null, Severity.ERROR, "IBM_COUNT_MISMATCH",
                        skinLabel + " inverseBindMatrices has " + ibmCount +
                        " entries, skin has " + joints.size() + " joints — " +
                        "re-export with the skin's armature selected"
                    ));
                }
            }
        }
    }

    // ------------------------------------------------------------------ //
    //  Mesh validation                                                     //
    // ------------------------------------------------------------------ //

    private static void validateMeshes(
        JsonObject root,
        ResourceLocation source,
        List<GlbDiagnostic> diagnostics
    ) {
        if (!root.has("meshes")) {
            return;
        }

        JsonArray meshes = root.getAsJsonArray("meshes");

        // Count non-Player meshes
        int nonPlayerCount = 0;
        for (int mi = 0; mi < meshes.size(); mi++) {
            JsonObject mesh = meshes.get(mi).getAsJsonObject();
            String meshName = mesh.has("name")
                ? mesh.get("name").getAsString()
                : "";
            if (!com.tiedup.remake.client.gltf.GlbParserUtils.isPlayerMesh(meshName)) {
                nonPlayerCount++;
            }
        }

        if (nonPlayerCount > 1) {
            diagnostics.add(new GlbDiagnostic(
                source, null, Severity.WARNING, "MULTI_MESH_AMBIGUITY",
                nonPlayerCount + " non-Player meshes found — name your item mesh 'Item' "
                    + "for explicit selection"
            ));
        }

        // Check WEIGHTS_0 on the mesh that GlbParser would actually select:
        // 1) mesh named "Item", 2) last non-Player mesh.
        JsonObject targetMesh = null;
        String targetMeshName = 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)) {
                targetMesh = mesh;
                targetMeshName = meshName;
                break; // Convention match — same as GlbParser
            }
            if (!com.tiedup.remake.client.gltf.GlbParserUtils.isPlayerMesh(meshName)) {
                targetMesh = mesh;
                targetMeshName = meshName;
            }
        }

        if (targetMesh != null && targetMesh.has("primitives")) {
            JsonArray primitives = targetMesh.getAsJsonArray("primitives");
            if (primitives.size() > 0) {
                JsonObject firstPrimitive = primitives.get(0).getAsJsonObject();
                if (firstPrimitive.has("attributes")) {
                    JsonObject attributes = firstPrimitive.getAsJsonObject("attributes");
                    String meshLabel = targetMeshName != null
                        ? "'" + targetMeshName + "'"
                        : "(unnamed)";
                    if (!attributes.has("WEIGHTS_0")) {
                        diagnostics.add(new GlbDiagnostic(
                            source, null, Severity.WARNING, "NO_WEIGHTS",
                            "Selected mesh " + meshLabel
                                + " first primitive has no WEIGHTS_0 attribute "
                                + "— skinning will not work correctly"
                        ));
                    }
                    // Without JOINTS_0 every vertex implicitly binds to joint
                    // 0 (root), so the item renders attached to the root bone
                    // regardless of how the artist weighted it in Blender.
                    if (!attributes.has("JOINTS_0")) {
                        diagnostics.add(new GlbDiagnostic(
                            source, null, Severity.WARNING, "NO_JOINTS",
                            "Selected mesh " + meshLabel
                                + " first primitive has no JOINTS_0 attribute "
                                + "— vertices will all bind to root joint (bind pose render)"
                        ));
                    }
                    // Weight-sum authoring check intentionally omitted: the
                    // only cheap signal available here (WEIGHTS_0 accessor
                    // min/max) is per-component across all vertices, which
                    // cannot be summed to reconstruct any single vertex's
                    // weight total (the min of component 0 comes from a
                    // different vertex than the min of component 1). The
                    // earlier heuristic produced both false positives
                    // (legitimate 1-influence meshes) and false negatives
                    // (mixed meshes where one vertex totals 0.9). The parser
                    // still normalizes weights at load, so the runtime path
                    // is safe; a proper authoring check would need to decode
                    // the BIN chunk and scan each vertex tuple. Deferred.
                }
            }
        }
    }

    // ------------------------------------------------------------------ //
    //  Animation validation                                                //
    // ------------------------------------------------------------------ //

    private static void validateAnimations(
        JsonObject root,
        ResourceLocation source,
        List<GlbDiagnostic> diagnostics
    ) {
        if (!root.has("animations")) {
            diagnostics.add(new GlbDiagnostic(
                source, null, Severity.WARNING, "NO_IDLE_ANIMATION",
                "GLB has no 'animations' array — no Idle animation found"
            ));
            return;
        }

        JsonArray animations = root.getAsJsonArray("animations");
        if (animations.size() == 0) {
            diagnostics.add(new GlbDiagnostic(
                source, null, Severity.WARNING, "NO_IDLE_ANIMATION",
                "GLB 'animations' array is empty — no Idle animation found"
            ));
            return;
        }

        boolean hasIdle = false;
        for (int ai = 0; ai < animations.size(); ai++) {
            JsonObject anim = animations.get(ai).getAsJsonObject();
            if (!anim.has("name")) {
                continue;
            }
            String animName = com.tiedup.remake.client.gltf.GlbParserUtils.stripArmaturePrefix(
                anim.get("name").getAsString()
            );
            if ("Idle".equals(animName)) {
                hasIdle = true;
                break;
            }
        }

        if (!hasIdle) {
            diagnostics.add(new GlbDiagnostic(
                source, null, Severity.WARNING, "NO_IDLE_ANIMATION",
                "No animation named 'Idle' found — the default rest pose may not display correctly"
            ));
        }

        // Animation channel target ∈ skin.joints: any channel that targets a
        // node NOT in the first skin's joints is silently dropped by the parser
        // (GlbParserUtils.parseAnimation: `nodeToJoint[nodeIdx] < 0 → skip`).
        // Most commonly: artist keyframes the Armature root object instead of
        // its bones. Warn so the artist can fix it in Blender.
        validateAnimationTargets(root, animations, source, diagnostics);
    }

    private static void validateAnimationTargets(
        JsonObject root,
        JsonArray animations,
        ResourceLocation source,
        List<GlbDiagnostic> diagnostics
    ) {
        if (!root.has("skins")) return;
        JsonArray skins = root.getAsJsonArray("skins");
        if (skins.size() == 0) return;
        JsonObject skin = skins.get(0).getAsJsonObject();
        if (!skin.has("joints")) return;
        JsonArray joints = skin.getAsJsonArray("joints");
        java.util.Set<Integer> jointNodes = new java.util.HashSet<>();
        for (int j = 0; j < joints.size(); j++) {
            jointNodes.add(joints.get(j).getAsInt());
        }

        int droppedChannels = 0;
        int totalChannels = 0;
        for (int ai = 0; ai < animations.size(); ai++) {
            JsonObject anim = animations.get(ai).getAsJsonObject();
            if (!anim.has("channels")) continue;
            JsonArray channels = anim.getAsJsonArray("channels");
            for (int ci = 0; ci < channels.size(); ci++) {
                JsonObject ch = channels.get(ci).getAsJsonObject();
                if (!ch.has("target")) continue;
                JsonObject target = ch.getAsJsonObject("target");
                if (!target.has("node")) continue;
                totalChannels++;
                int nodeIdx = target.get("node").getAsInt();
                if (!jointNodes.contains(nodeIdx)) {
                    droppedChannels++;
                }
            }
        }

        if (droppedChannels > 0) {
            diagnostics.add(new GlbDiagnostic(
                source,
                null,
                Severity.WARNING,
                "ANIM_CHANNEL_NOT_IN_SKIN",
                droppedChannels +
                " / " +
                totalChannels +
                " animation channel(s) target node(s) outside skin.joints — " +
                "these channels will be silently dropped by the runtime. " +
                "Keyframe the armature's bones, not the Armature object itself."
            ));
        }
    }
}
