package com.tiedup.remake.v2.bondage.datadriven;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.LivingMotions;
import com.tiedup.remake.rig.anim.TiedUpLivingMotions;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.component.ComponentType;
import com.tiedup.remake.v2.bondage.movement.MovementModifier;
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap;
import java.util.EnumSet;
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.minecraft.resources.ResourceLocation;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.Nullable;

/**
 * Parses JSON files into {@link DataDrivenItemDefinition} instances.
 *
 * <p>Uses manual field extraction (not Gson deserialization) for validation control.
 * Invalid fields are logged as warnings; critical errors (missing type, empty regions,
 * missing model) cause the entire definition to be skipped.</p>
 *
 * <p>Expected JSON format:
 * <pre>{@code
 * {
 *   "type": "tiedup:bondage_item",
 *   "display_name": "Leather Armbinder",
 *   "translation_key": "item.tiedup.leather_armbinder",
 *   "model": "tiedup:models/gltf/v2/armbinder/armbinder.glb",
 *   "slim_model": "tiedup:models/gltf/v2/armbinder/armbinder_slim.glb",
 *   "animation_source": "tiedup:models/gltf/v2/armbinder/armbinder_anim.glb",
 *   "regions": ["ARMS", "HANDS", "TORSO"],
 *   "blocked_regions": ["ARMS", "HANDS", "TORSO", "FINGERS"],
 *   "pose_type": "STANDARD",
 *   "pose_priority": 50,
 *   "escape_difficulty": 150,
 *   "resistance_id": "armbinder",
 *   "lockable": true,
 *   "supports_color": false,
 *   "color_variants": []
 * }
 * }</pre>
 */
public final class DataDrivenItemParser {

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

    private DataDrivenItemParser() {}

    /**
     * Parse a JSON input stream into a DataDrivenItemDefinition.
     *
     * @param input  the JSON input stream
     * @param fileId the resource location of the file (for error messages)
     * @return the parsed definition, or null if the file is invalid
     */
    @Nullable
    public static DataDrivenItemDefinition parse(
        InputStream input,
        ResourceLocation fileId
    ) {
        try {
            JsonObject root = JsonParser.parseReader(
                new InputStreamReader(input, StandardCharsets.UTF_8)
            ).getAsJsonObject();

            return parseObject(root, fileId);
        } catch (Exception e) {
            LOGGER.error(
                "[DataDrivenItems] Failed to parse JSON {}: {}",
                fileId,
                e.getMessage()
            );
            return null;
        }
    }

    /**
     * Parse a JsonObject into a DataDrivenItemDefinition.
     *
     * @param root   the parsed JSON object
     * @param fileId the resource location of the file (for error messages)
     * @return the parsed definition, or null if validation fails
     */
    @Nullable
    public static DataDrivenItemDefinition parseObject(
        JsonObject root,
        ResourceLocation fileId
    ) {
        // Validate type field
        String type = getStringOrNull(root, "type");
        if (!"tiedup:bondage_item".equals(type)) {
            LOGGER.error(
                "[DataDrivenItems] Skipping {}: invalid or missing type '{}' (expected 'tiedup:bondage_item')",
                fileId,
                type
            );
            return null;
        }

        // Required: display_name
        String displayName = getStringOrNull(root, "display_name");
        if (displayName == null || displayName.isEmpty()) {
            LOGGER.error(
                "[DataDrivenItems] Skipping {}: missing 'display_name'",
                fileId
            );
            return null;
        }

        // Optional: translation_key
        String translationKey = getStringOrNull(root, "translation_key");

        // Required: model
        String modelStr = getStringOrNull(root, "model");
        if (modelStr == null || modelStr.isEmpty()) {
            LOGGER.error(
                "[DataDrivenItems] Skipping {}: missing 'model'",
                fileId
            );
            return null;
        }
        ResourceLocation modelLocation = ResourceLocation.tryParse(modelStr);
        if (modelLocation == null) {
            LOGGER.error(
                "[DataDrivenItems] Skipping {}: invalid model ResourceLocation '{}'",
                fileId,
                modelStr
            );
            return null;
        }

        // Optional: slim_model
        ResourceLocation slimModelLocation = parseOptionalResourceLocation(
            root,
            "slim_model",
            fileId
        );

        // Optional: animation_source
        ResourceLocation animationSource = parseOptionalResourceLocation(
            root,
            "animation_source",
            fileId
        );

        // Required: regions (non-empty)
        Set<BodyRegionV2> occupiedRegions = parseRegions(
            root,
            "regions",
            fileId
        );
        if (occupiedRegions == null || occupiedRegions.isEmpty()) {
            LOGGER.error(
                "[DataDrivenItems] Skipping {}: missing or empty 'regions'",
                fileId
            );
            return null;
        }

        // Optional: blocked_regions (defaults to regions)
        Set<BodyRegionV2> blockedRegions = parseRegions(
            root,
            "blocked_regions",
            fileId
        );
        if (blockedRegions == null || blockedRegions.isEmpty()) {
            blockedRegions = occupiedRegions;
        }

        // Optional: pose_type (e.g., "STANDARD", "STRAITJACKET", "DOG")
        String poseType = getStringOrNull(root, "pose_type");

        // Optional: pose_priority (default 0)
        int posePriority = getIntOrDefault(root, "pose_priority", 0);

        // Optional: escape_difficulty (default 0)
        int escapeDifficulty = getIntOrDefault(root, "escape_difficulty", 0);

        // Optional: lockable (default true)
        boolean lockable = getBooleanOrDefault(root, "lockable", true);

        // Optional: can_attach_padlock (default true). False for organic items.
        boolean canAttachPadlock = getBooleanOrDefault(root, "can_attach_padlock", true);

        // Optional: supports_color (default false)
        boolean supportsColor = getBooleanOrDefault(
            root,
            "supports_color",
            false
        );

        // Optional: tint_channels (default empty)
        Map<String, Integer> tintChannels = parseTintChannels(
            root,
            "tint_channels",
            fileId
        );

        // Optional: icon (item model ResourceLocation for inventory sprite)
        ResourceLocation icon = parseOptionalResourceLocation(
            root,
            "icon",
            fileId
        );

        // Optional: movement_style (requires valid MovementStyle name)
        MovementStyle movementStyle = null;
        String movementStyleStr = getStringOrNull(root, "movement_style");
        if (movementStyleStr != null && !movementStyleStr.isEmpty()) {
            movementStyle = MovementStyle.fromName(movementStyleStr);
            if (movementStyle == null) {
                LOGGER.warn(
                    "[DataDrivenItems] In {}: unknown movement_style '{}', ignoring",
                    fileId,
                    movementStyleStr
                );
            }
        }

        // Optional: movement_modifier (requires movement_style to be set)
        MovementModifier movementModifier = null;
        if (
            movementStyle != null &&
            root.has("movement_modifier") &&
            root.get("movement_modifier").isJsonObject()
        ) {
            JsonObject modObj = root.getAsJsonObject("movement_modifier");
            Float speedMul = getFloatOrNull(modObj, "speed_multiplier");
            Boolean jumpDis = getBooleanOrNull(modObj, "jump_disabled");
            if (speedMul != null || jumpDis != null) {
                movementModifier = new MovementModifier(speedMul, jumpDis);
            }
        } else if (movementStyle == null && root.has("movement_modifier")) {
            LOGGER.warn(
                "[DataDrivenItems] In {}: movement_modifier ignored because movement_style is absent",
                fileId
            );
        }

        // Optional: creator (author name for tooltip)
        String creator = getStringOrNull(root, "creator");

        // Optional: animation_bones (per-animation bone whitelist)
        // When absent, all owned bones are enabled for all animations.
        Map<String, Set<String>> animationBones;
        if (!root.has("animation_bones")) {
            // Not an error — absent means "permissive" (all owned bones, all animations)
            animationBones = null;
            LOGGER.debug(
                "[DataDrivenItems] {}: animation_bones not declared — all owned bones enabled for all animations",
                fileId
            );
            // null is fine — the record constructor defaults it to Map.of()
        } else {
            animationBones = parseAnimationBones(root, fileId);
            if (animationBones == null) {
                // Field was present but malformed — parseAnimationBones already logged details
                LOGGER.error(
                    "[DataDrivenItems] Skipping {}: 'animation_bones' is present but invalid",
                    fileId
                );
                return null;
            }
        }

        // Optional: components (per-component JSON configs)
        Map<ComponentType, JsonObject> componentConfigs =
            new EnumMap<>(ComponentType.class);
        if (root.has("components")) {
            JsonObject componentsObj = root.getAsJsonObject("components");
            for (Map.Entry<String, JsonElement> entry :
                componentsObj.entrySet()) {
                ComponentType compType = ComponentType.fromKey(
                    entry.getKey()
                );
                if (compType != null) {
                    JsonObject config;
                    if (entry.getValue().isJsonObject()) {
                        config = entry.getValue().getAsJsonObject().deepCopy();
                    } else {
                        LOGGER.warn(
                            "[DataDrivenItemParser] Component '{}' in item '{}' has non-object config, using defaults",
                            entry.getKey(),
                            fileId
                        );
                        config = new JsonObject();
                    }
                    componentConfigs.put(compType, config);
                } else {
                    LOGGER.warn(
                        "[DataDrivenItemParser] Unknown component type '{}' in item '{}'",
                        entry.getKey(),
                        fileId
                    );
                }
            }
        }

        // Build the item ID from the file path
        // fileId is like "tiedup:tiedup_items/leather_armbinder.json"
        // We want "tiedup:leather_armbinder"
        String idPath = fileId.getPath();
        // Strip "tiedup_items/" prefix
        if (idPath.startsWith("tiedup_items/")) {
            idPath = idPath.substring("tiedup_items/".length());
        }
        // Strip ".json" suffix
        if (idPath.endsWith(".json")) {
            idPath = idPath.substring(0, idPath.length() - 5);
        }
        ResourceLocation id = ResourceLocation.fromNamespaceAndPath(
            fileId.getNamespace(),
            idPath
        );

        // Optional: animations (living_motions + on_equip + on_unequip)
        // P3-03 : replaces the hardcoded null from P3-02.
        // When the "animations" key is absent in JSON, we keep null so that
        // items authored before P3-03 keep their vanilla animator behavior.
        AnimationBindings animations = parseAnimationBindings(root, id);

        return new DataDrivenItemDefinition(
            id,
            displayName,
            translationKey,
            modelLocation,
            slimModelLocation,
            animationSource,
            occupiedRegions,
            blockedRegions,
            poseType,
            posePriority,
            escapeDifficulty,
            lockable,
            canAttachPadlock,
            supportsColor,
            tintChannels,
            icon,
            movementStyle,
            movementModifier,
            creator,
            animationBones,
            animations,
            componentConfigs
        );
    }

    // ===== Helper Methods =====

    @Nullable
    private static String getStringOrNull(JsonObject obj, String key) {
        if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
        try {
            return obj.get(key).getAsString();
        } catch (Exception e) {
            return null;
        }
    }

    private static int getIntOrDefault(
        JsonObject obj,
        String key,
        int defaultValue
    ) {
        if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
        try {
            return obj.get(key).getAsInt();
        } catch (Exception e) {
            return defaultValue;
        }
    }

    private static boolean getBooleanOrDefault(
        JsonObject obj,
        String key,
        boolean defaultValue
    ) {
        if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
        try {
            return obj.get(key).getAsBoolean();
        } catch (Exception e) {
            return defaultValue;
        }
    }

    @Nullable
    private static Float getFloatOrNull(JsonObject obj, String key) {
        if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
        try {
            return obj.get(key).getAsFloat();
        } catch (Exception e) {
            return null;
        }
    }

    @Nullable
    private static Boolean getBooleanOrNull(JsonObject obj, String key) {
        if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
        try {
            return obj.get(key).getAsBoolean();
        } catch (Exception e) {
            return null;
        }
    }

    @Nullable
    private static ResourceLocation parseOptionalResourceLocation(
        JsonObject obj,
        String key,
        ResourceLocation fileId
    ) {
        String value = getStringOrNull(obj, key);
        if (value == null || value.isEmpty()) return null;
        ResourceLocation loc = ResourceLocation.tryParse(value);
        if (loc == null) {
            LOGGER.warn(
                "[DataDrivenItems] In {}: invalid ResourceLocation for '{}': '{}'",
                fileId,
                key,
                value
            );
        }
        return loc;
    }

    /**
     * Parse a JSON string array into an EnumSet of BodyRegionV2.
     * Unknown region names are logged as warnings and skipped.
     */
    @Nullable
    private static Set<BodyRegionV2> parseRegions(
        JsonObject obj,
        String key,
        ResourceLocation fileId
    ) {
        if (!obj.has(key) || !obj.get(key).isJsonArray()) return null;

        JsonArray arr = obj.getAsJsonArray(key);
        if (arr.isEmpty()) return null;

        EnumSet<BodyRegionV2> regions = EnumSet.noneOf(BodyRegionV2.class);
        for (JsonElement elem : arr) {
            try {
                String name = elem.getAsString().toUpperCase();
                BodyRegionV2 region = BodyRegionV2.fromName(name);
                if (region != null) {
                    regions.add(region);
                } else {
                    LOGGER.warn(
                        "[DataDrivenItems] In {}: unknown region '{}' in '{}', skipping",
                        fileId,
                        name,
                        key
                    );
                }
            } catch (Exception e) {
                LOGGER.warn(
                    "[DataDrivenItems] In {}: invalid element in '{}': {}",
                    fileId,
                    key,
                    e.getMessage()
                );
            }
        }

        return regions.isEmpty() ? null : Collections.unmodifiableSet(regions);
    }

    /**
     * Parse a tint_channels JSON object mapping channel names to hex color strings.
     *
     * <p>Example JSON:
     * <pre>{@code
     * "tint_channels": {
     *   "tintable_0": "#8B4513",
     *   "tintable_1": "#FF0000"
     * }
     * }</pre>
     *
     * @param obj    the parent JSON object
     * @param key    the field name to parse
     * @param fileId the source file for error messages
     * @return an unmodifiable map of channel names to RGB ints, or empty map if absent
     */
    private static Map<String, Integer> parseTintChannels(
        JsonObject obj,
        String key,
        ResourceLocation fileId
    ) {
        if (!obj.has(key) || !obj.get(key).isJsonObject()) return Map.of();
        JsonObject channels = obj.getAsJsonObject(key);
        Map<String, Integer> result = new LinkedHashMap<>();
        for (Map.Entry<String, JsonElement> entry : channels.entrySet()) {
            try {
                String hex = entry.getValue().getAsString();
                int color = Integer.parseInt(
                    hex.startsWith("#") ? hex.substring(1) : hex,
                    16
                );
                result.put(entry.getKey(), color);
            } catch (NumberFormatException e) {
                LOGGER.warn(
                    "[DataDrivenItems] In {}: invalid hex color '{}' for tint channel '{}'",
                    fileId,
                    entry.getValue(),
                    entry.getKey()
                );
            }
        }
        return Collections.unmodifiableMap(result);
    }

    /** Valid PlayerAnimator bone names for animation_bones validation. */
    private static final Set<String> VALID_BONE_NAMES = Set.of(
        "head",
        "body",
        "rightArm",
        "leftArm",
        "rightLeg",
        "leftLeg"
    );

    /**
     * Parse the {@code animation_bones} JSON object.
     *
     * <p>Format:
     * <pre>{@code
     * "animation_bones": {
     *   "idle": ["rightArm", "leftArm"],
     *   "struggle": ["rightArm", "leftArm", "body"]
     * }
     * }</pre>
     *
     * <p>Each key is an animation name, each value is a JSON array of bone name strings.
     * Bone names are validated against the 6 PlayerAnimator parts. Invalid bone names
     * are logged as warnings and skipped. Empty arrays or unknown-only arrays cause the
     * entire animation entry to be skipped.</p>
     *
     * @param obj    the parent JSON object
     * @param fileId the source file for error messages
     * @return unmodifiable map of animation name to bone set, or null if absent/invalid
     */
    @Nullable
    private static Map<String, Set<String>> parseAnimationBones(
        JsonObject obj,
        ResourceLocation fileId
    ) {
        if (
            !obj.has("animation_bones") ||
            !obj.get("animation_bones").isJsonObject()
        ) {
            return null;
        }

        JsonObject bonesObj = obj.getAsJsonObject("animation_bones");
        if (bonesObj.size() == 0) {
            LOGGER.error(
                "[DataDrivenItems] In {}: 'animation_bones' is empty",
                fileId
            );
            return null;
        }

        Map<String, Set<String>> result = new LinkedHashMap<>();
        for (Map.Entry<String, JsonElement> entry : bonesObj.entrySet()) {
            String animName = entry.getKey();
            JsonElement value = entry.getValue();

            if (!value.isJsonArray()) {
                LOGGER.warn(
                    "[DataDrivenItems] In {}: animation_bones['{}'] is not an array, skipping",
                    fileId,
                    animName
                );
                continue;
            }

            JsonArray boneArray = value.getAsJsonArray();
            Set<String> bones = new HashSet<>();
            for (JsonElement boneElem : boneArray) {
                try {
                    String boneName = boneElem.getAsString();
                    if (VALID_BONE_NAMES.contains(boneName)) {
                        bones.add(boneName);
                    } else {
                        LOGGER.warn(
                            "[DataDrivenItems] In {}: animation_bones['{}'] contains unknown bone '{}', skipping",
                            fileId,
                            animName,
                            boneName
                        );
                    }
                } catch (Exception e) {
                    LOGGER.warn(
                        "[DataDrivenItems] In {}: invalid element in animation_bones['{}']",
                        fileId,
                        animName
                    );
                }
            }

            if (!bones.isEmpty()) {
                result.put(animName, Collections.unmodifiableSet(bones));
            } else {
                LOGGER.warn(
                    "[DataDrivenItems] In {}: animation_bones['{}'] resolved to empty set, skipping",
                    fileId,
                    animName
                );
            }
        }

        if (result.isEmpty()) {
            LOGGER.error(
                "[DataDrivenItems] In {}: 'animation_bones' has no valid entries",
                fileId
            );
            return null;
        }

        return Collections.unmodifiableMap(result);
    }

    // ===== Animations (P3-03) =====

    /**
     * Parse the optional {@code "animations"} JSON block into an
     * {@link AnimationBindings}.
     *
     * <p>Format :
     * <pre>{@code
     * "animations": {
     *   "living_motions": {
     *     "WALK": "mymod:arms_cuffed_walk",
     *     "SNEAK": "mymod:arms_cuffed_sneak"
     *   },
     *   "on_equip": "mymod:cuffs_equip_oneshot",
     *   "on_unequip": "mymod:cuffs_unequip_oneshot"
     * }
     * }</pre>
     *
     * <p>Tolerance rules :
     * <ul>
     *   <li>Key absent &rarr; returns {@code null} (no binding, vanilla behavior).</li>
     *   <li>Key present but {@code {}} &rarr; returns {@link AnimationBindings#EMPTY}.</li>
     *   <li>Unknown motion names are logged with a Levenshtein fuzzy-match suggestion
     *       and skipped (no crash).</li>
     *   <li>Malformed ResourceLocation strings are logged and skipped.</li>
     *   <li>Non-string values in {@code living_motions} are logged and skipped.</li>
     * </ul>
     *
     * @param rootJson the parsed root JSON object of the item definition
     * @param itemId   the resolved item ID (used in log messages)
     * @return the parsed bindings, {@link AnimationBindings#EMPTY} for an empty block,
     *         or {@code null} if the {@code "animations"} key is absent
     */
    @Nullable
    static AnimationBindings parseAnimationBindings(
        JsonObject rootJson,
        ResourceLocation itemId
    ) {
        if (!rootJson.has("animations")) return null;

        JsonElement animElem = rootJson.get("animations");
        if (!animElem.isJsonObject()) {
            LOGGER.warn(
                "[DataDrivenItems] In {}: 'animations' is not an object, ignoring",
                itemId
            );
            return null;
        }
        JsonObject animBlock = animElem.getAsJsonObject();

        // Fast-path : present but empty -> EMPTY sentinel (distinguishable from "absent = null").
        if (animBlock.size() == 0) return AnimationBindings.EMPTY;

        // living_motions : optional map of motionName -> animationResourceLocation
        Map<LivingMotion, ResourceLocation> motions = new HashMap<>();
        if (animBlock.has("living_motions")) {
            JsonElement motionsElem = animBlock.get("living_motions");
            if (!motionsElem.isJsonObject()) {
                LOGGER.warn(
                    "[DataDrivenItems] In {}: 'animations.living_motions' is not an object, skipping",
                    itemId
                );
            } else {
                JsonObject motionsJson = motionsElem.getAsJsonObject();
                for (Map.Entry<String, JsonElement> entry : motionsJson.entrySet()) {
                    String motionName = entry.getKey();
                    LivingMotion motion = resolveMotionByName(motionName);
                    if (motion == null) {
                        String suggest = suggestClosestMotion(motionName);
                        LOGGER.warn(
                            "[DataDrivenItemParser] Unknown motion '{}' in item {}, skipping.{}",
                            motionName,
                            itemId,
                            suggest != null ? " Did you mean '" + suggest + "'?" : ""
                        );
                        continue;
                    }
                    ResourceLocation animId = tryParseAnimationRL(
                        entry.getValue(),
                        "living_motions['" + motionName + "']",
                        itemId
                    );
                    if (animId == null) continue;
                    motions.put(motion, animId);
                }
            }
        }

        // on_equip / on_unequip : optional single RL strings
        ResourceLocation onEquip = animBlock.has("on_equip")
            ? tryParseAnimationRL(animBlock.get("on_equip"), "on_equip", itemId)
            : null;
        ResourceLocation onUnequip = animBlock.has("on_unequip")
            ? tryParseAnimationRL(animBlock.get("on_unequip"), "on_unequip", itemId)
            : null;

        return new AnimationBindings(motions, onEquip, onUnequip);
    }

    /**
     * Resolve a motion name string to its {@link LivingMotion} instance.
     *
     * <p>Both vanilla-EF {@link LivingMotions} and custom {@link TiedUpLivingMotions}
     * are standard Java enums, so {@link Enum#valueOf(Class, String)} is used. The
     * lookup is case-sensitive (must match the enum constant name exactly).
     *
     * @param name the motion name from JSON (e.g. "WALK", "STRUGGLE_BOUND")
     * @return the resolved {@link LivingMotion}, or {@code null} if unknown in both enums
     */
    @Nullable
    static LivingMotion resolveMotionByName(String name) {
        if (name == null || name.isEmpty()) return null;
        // Try vanilla EF motions first (most common case)
        try {
            return LivingMotions.valueOf(name);
        } catch (IllegalArgumentException ignored) {
            // fall through
        }
        // Try TiedUp! custom motions
        try {
            return TiedUpLivingMotions.valueOf(name);
        } catch (IllegalArgumentException ignored) {
            // fall through
        }
        return null;
    }

    /**
     * Suggest the closest known motion name for an unknown input, using
     * Levenshtein edit distance with a tolerance of 3 edits.
     *
     * <p>Cross-enum search (vanilla {@link LivingMotions} + {@link TiedUpLivingMotions}).
     *
     * @param unknown the unknown name from JSON (case-insensitive comparison)
     * @return the closest candidate (enum name), or {@code null} if no candidate
     *         is within 3 edits
     */
    @Nullable
    static String suggestClosestMotion(String unknown) {
        if (unknown == null || unknown.isEmpty()) return null;
        String needle = unknown.toUpperCase();

        List<String> all = new ArrayList<>();
        for (LivingMotions m : LivingMotions.values()) all.add(m.name());
        for (TiedUpLivingMotions m : TiedUpLivingMotions.values()) all.add(m.name());

        String best = null;
        int bestDist = Integer.MAX_VALUE;
        for (String candidate : all) {
            int dist = levenshtein(needle, candidate);
            if (dist < bestDist && dist <= 3) {
                bestDist = dist;
                best = candidate;
            }
        }
        return best;
    }

    /**
     * Classic Levenshtein edit distance (2D dynamic programming).
     *
     * <p>Pure computation, no allocations beyond the DP table. Used for typo
     * suggestions on unknown motion names.
     */
    static int levenshtein(String a, String b) {
        if (a == null) a = "";
        if (b == null) b = "";
        int la = a.length();
        int lb = b.length();
        if (la == 0) return lb;
        if (lb == 0) return la;

        int[][] dp = new int[la + 1][lb + 1];
        for (int i = 0; i <= la; i++) dp[i][0] = i;
        for (int j = 0; j <= lb; j++) dp[0][j] = j;

        for (int i = 1; i <= la; i++) {
            for (int j = 1; j <= lb; j++) {
                int cost = (a.charAt(i - 1) == b.charAt(j - 1)) ? 0 : 1;
                dp[i][j] = Math.min(
                    Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1),
                    dp[i - 1][j - 1] + cost
                );
            }
        }
        return dp[la][lb];
    }

    /**
     * Parse a JSON element as a ResourceLocation for an animation binding.
     * Logs warn + returns null if the element is not a string or the string
     * is not a valid RL.
     */
    @Nullable
    private static ResourceLocation tryParseAnimationRL(
        JsonElement elem,
        String context,
        ResourceLocation itemId
    ) {
        if (elem == null || elem.isJsonNull() || !elem.isJsonPrimitive()
            || !elem.getAsJsonPrimitive().isString()) {
            LOGGER.warn(
                "[DataDrivenItemParser] {} in item {} is not a string, skipping.",
                context,
                itemId
            );
            return null;
        }
        String s = elem.getAsString();
        // Strict RL parsing : animations MUST carry an explicit namespace
        // (e.g. "mymod:foo"). ResourceLocation.tryParse defaults bare paths
        // to the "minecraft" namespace, which silently masks modder typos —
        // we reject strings without ':' here.
        if (s.isEmpty() || s.indexOf(':') < 0) {
            LOGGER.warn(
                "[DataDrivenItemParser] Malformed ResourceLocation '{}' for {} in item {}, skipping.",
                s,
                context,
                itemId
            );
            return null;
        }
        ResourceLocation rl = ResourceLocation.tryParse(s);
        if (rl == null) {
            LOGGER.warn(
                "[DataDrivenItemParser] Malformed ResourceLocation '{}' for {} in item {}, skipping.",
                s,
                context,
                itemId
            );
        }
        return rl;
    }
}
