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.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.Collections;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.LinkedHashMap;
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",
 *   "texture": "tiedup:textures/item/armbinder",
 *   "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: texture
        ResourceLocation texturePath = parseOptionalResourceLocation(
            root,
            "texture",
            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");

        // Required: animation_bones (per-animation bone whitelist)
        Map<String, Set<String>> animationBones = parseAnimationBones(
            root,
            fileId
        );
        if (animationBones == null) {
            LOGGER.error(
                "[DataDrivenItems] Skipping {}: missing or invalid 'animation_bones'",
                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 = new ResourceLocation(
            fileId.getNamespace(),
            idPath
        );

        return new DataDrivenItemDefinition(
            id,
            displayName,
            translationKey,
            modelLocation,
            slimModelLocation,
            texturePath,
            animationSource,
            occupiedRegions,
            blockedRegions,
            poseType,
            posePriority,
            escapeDifficulty,
            lockable,
            canAttachPadlock,
            supportsColor,
            tintChannels,
            icon,
            movementStyle,
            movementModifier,
            creator,
            animationBones,
            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);
    }
}
