package com.tiedup.remake.v2.furniture;

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 java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
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 FurnitureDefinition} instances.
 *
 * <p>Uses manual field extraction (not Gson deserialization) for strict
 * validation control. Invalid required fields cause the entire definition
 * to be rejected; optional fields use safe defaults.</p>
 *
 * <p>Expected JSON files in {@code data/<namespace>/tiedup_furniture/}.</p>
 */
public final class FurnitureParser {

    private static final Logger LOGGER = LogManager.getLogger(
        "TiedUpFurniture"
    );
    private static final String TAG = "[FurnitureParser]";

    /** Strict hex color pattern: # followed by exactly 6 hex digits. */
    private static final Pattern HEX_COLOR = Pattern.compile(
        "^#[0-9A-Fa-f]{6}$"
    );

    /** Maximum number of seats per furniture (bitmask limit: 8 bits). */
    private static final int MAX_SEATS = 8;

    private FurnitureParser() {}

    /**
     * Parse a JSON input stream into a FurnitureDefinition.
     *
     * @param input  the JSON input stream
     * @param fileId the resource location of the source file (for error messages)
     * @return the parsed definition, or null if the file is invalid
     */
    @Nullable
    public static FurnitureDefinition 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(
                "{} Failed to parse JSON {}: {}",
                TAG,
                fileId,
                e.getMessage()
            );
            return null;
        }
    }

    /**
     * Parse a JsonObject into a FurnitureDefinition.
     *
     * @param root   the parsed JSON object
     * @param fileId the resource location of the source file (for error messages)
     * @return the parsed definition, or null if validation fails
     */
    @Nullable
    public static FurnitureDefinition parseObject(
        JsonObject root,
        ResourceLocation fileId
    ) {
        // --- Required: id ---
        String idStr = getStringOrNull(root, "id");
        if (idStr == null || idStr.isEmpty()) {
            LOGGER.error("{} Skipping {}: missing 'id'", TAG, fileId);
            return null;
        }
        ResourceLocation id = ResourceLocation.tryParse(idStr);
        if (id == null) {
            LOGGER.error(
                "{} Skipping {}: invalid id ResourceLocation '{}'",
                TAG,
                fileId,
                idStr
            );
            return null;
        }

        // --- Required: display_name ---
        String displayName = getStringOrNull(root, "display_name");
        if (displayName == null || displayName.isEmpty()) {
            LOGGER.error("{} Skipping {}: missing 'display_name'", TAG, 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("{} Skipping {}: missing 'model'", TAG, fileId);
            return null;
        }
        ResourceLocation modelLocation = ResourceLocation.tryParse(modelStr);
        if (modelLocation == null) {
            LOGGER.error(
                "{} Skipping {}: invalid model ResourceLocation '{}'",
                TAG,
                fileId,
                modelStr
            );
            return null;
        }

        // --- Optional: tint_channels (strict hex validation) ---
        Map<String, Integer> tintChannels = parseTintChannels(root, fileId);
        if (tintChannels == null) {
            // parseTintChannels returns null on invalid hex -> reject entire furniture
            return null;
        }

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

        // --- Optional: hitbox (defaults: 1.0 x 1.0, clamped [0.1, 5.0]) ---
        float hitboxWidth = 1.0f;
        float hitboxHeight = 1.0f;
        if (root.has("hitbox") && root.get("hitbox").isJsonObject()) {
            JsonObject hitbox = root.getAsJsonObject("hitbox");
            hitboxWidth = clamp(
                getFloatOrDefault(hitbox, "width", 1.0f),
                0.1f,
                5.0f
            );
            hitboxHeight = clamp(
                getFloatOrDefault(hitbox, "height", 1.0f),
                0.1f,
                5.0f
            );
        }

        // --- Optional: placement ---
        boolean snapToWall = false;
        boolean floorOnly = true;
        if (root.has("placement") && root.get("placement").isJsonObject()) {
            JsonObject placement = root.getAsJsonObject("placement");
            snapToWall = getBooleanOrDefault(placement, "snap_to_wall", false);
            floorOnly = getBooleanOrDefault(placement, "floor_only", true);
        }

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

        // --- Optional: break_resistance (default 100, clamped [1, 10000]) ---
        float breakResistance = clamp(
            getFloatOrDefault(root, "break_resistance", 100.0f),
            1.0f,
            10000.0f
        );

        // --- Optional: drop_on_break (default true) ---
        boolean dropOnBreak = getBooleanOrDefault(root, "drop_on_break", true);

        // --- Required: seats (non-empty array, size [1, 8]) ---
        if (!root.has("seats") || !root.get("seats").isJsonArray()) {
            LOGGER.error(
                "{} Skipping {}: missing or invalid 'seats' array",
                TAG,
                fileId
            );
            return null;
        }
        JsonArray seatsArray = root.getAsJsonArray("seats");
        if (seatsArray.isEmpty()) {
            LOGGER.error("{} Skipping {}: 'seats' array is empty", TAG, fileId);
            return null;
        }
        if (seatsArray.size() > MAX_SEATS) {
            LOGGER.error(
                "{} Skipping {}: 'seats' array has {} entries (max {})",
                TAG,
                fileId,
                seatsArray.size(),
                MAX_SEATS
            );
            return null;
        }

        List<SeatDefinition> seats = new ArrayList<>(seatsArray.size());
        for (int i = 0; i < seatsArray.size(); i++) {
            if (!seatsArray.get(i).isJsonObject()) {
                LOGGER.error(
                    "{} Skipping {}: seats[{}] is not a JSON object",
                    TAG,
                    fileId,
                    i
                );
                return null;
            }
            SeatDefinition seat = parseSeat(
                seatsArray.get(i).getAsJsonObject(),
                i,
                lockable,
                fileId
            );
            if (seat == null) {
                // parseSeat already logged the error
                return null;
            }
            seats.add(seat);
        }

        // --- Optional: feedback ---
        FurnitureFeedback feedback = FurnitureFeedback.EMPTY;
        if (root.has("feedback") && root.get("feedback").isJsonObject()) {
            feedback = parseFeedback(root.getAsJsonObject("feedback"), fileId);
        }

        // --- Optional: category (default "furniture") ---
        String category = getStringOrDefault(root, "category", "furniture");

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

        return new FurnitureDefinition(
            id,
            displayName,
            translationKey,
            modelLocation,
            tintChannels,
            supportsColor,
            hitboxWidth,
            hitboxHeight,
            snapToWall,
            floorOnly,
            lockable,
            breakResistance,
            dropOnBreak,
            seats,
            feedback,
            category,
            icon
        );
    }

    // ===== Seat Parsing =====

    /**
     * Parse a single seat JSON object.
     *
     * @param obj      the seat JSON object
     * @param index    the seat index (for error messages)
     * @param parentLockable the top-level lockable value (used as default)
     * @param fileId   the source file (for error messages)
     * @return the parsed seat, or null on validation failure
     */
    @Nullable
    private static SeatDefinition parseSeat(
        JsonObject obj,
        int index,
        boolean parentLockable,
        ResourceLocation fileId
    ) {
        // Required: id (must not contain ':')
        String seatId = getStringOrNull(obj, "id");
        if (seatId == null || seatId.isEmpty()) {
            LOGGER.error(
                "{} Skipping {}: seats[{}] missing 'id'",
                TAG,
                fileId,
                index
            );
            return null;
        }
        // Reject separators used by SEAT_ASSIGNMENTS_SYNC encoding
        // ("uuid;seat|uuid;seat|…") — a seat id containing | or ; would
        // corrupt the client map on parse. `:` is reserved for the
        // ResourceLocation separator if this id is ever promoted to one.
        if (
            seatId.contains(":") ||
            seatId.contains("|") ||
            seatId.contains(";")
        ) {
            LOGGER.error(
                "{} Skipping {}: seats[{}] id '{}' must not contain ':', '|', or ';'",
                TAG,
                fileId,
                index,
                seatId
            );
            return null;
        }

        // Required: armature
        String armature = getStringOrNull(obj, "armature");
        if (armature == null || armature.isEmpty()) {
            LOGGER.error(
                "{} Skipping {}: seats[{}] missing 'armature'",
                TAG,
                fileId,
                index
            );
            return null;
        }

        // Optional: blocked_regions (unknown region = fatal for entire furniture)
        Set<BodyRegionV2> blockedRegions = parseBlockedRegions(
            obj,
            index,
            fileId
        );
        if (blockedRegions == null) {
            // parseBlockedRegions returns null ONLY on unknown region name (fatal)
            return null;
        }

        // Optional: lockable (inherits from top-level)
        boolean seatLockable = getBooleanOrDefault(
            obj,
            "lockable",
            parentLockable
        );

        // Optional: locked_difficulty (clamped [1, 10000], default 1)
        int lockedDifficulty = clampInt(
            getIntOrDefault(obj, "locked_difficulty", 1),
            1,
            10000
        );

        // Optional: item_difficulty_bonus (default false)
        boolean itemDifficultyBonus = getBooleanOrDefault(
            obj,
            "item_difficulty_bonus",
            false
        );

        return new SeatDefinition(
            seatId,
            armature,
            blockedRegions,
            seatLockable,
            lockedDifficulty,
            itemDifficultyBonus
        );
    }

    /**
     * Parse blocked_regions for a seat. Returns empty set if field is absent.
     * Returns null (fatal) if any region name is unknown.
     */
    @Nullable
    private static Set<BodyRegionV2> parseBlockedRegions(
        JsonObject obj,
        int seatIndex,
        ResourceLocation fileId
    ) {
        if (
            !obj.has("blocked_regions") ||
            !obj.get("blocked_regions").isJsonArray()
        ) {
            return Collections.unmodifiableSet(
                EnumSet.noneOf(BodyRegionV2.class)
            );
        }

        JsonArray arr = obj.getAsJsonArray("blocked_regions");
        if (arr.isEmpty()) {
            return Collections.unmodifiableSet(
                EnumSet.noneOf(BodyRegionV2.class)
            );
        }

        EnumSet<BodyRegionV2> regions = EnumSet.noneOf(BodyRegionV2.class);
        for (JsonElement elem : arr) {
            String name;
            try {
                name = elem.getAsString().toUpperCase();
            } catch (Exception e) {
                LOGGER.error(
                    "{} Skipping {}: seats[{}] invalid element in 'blocked_regions': {}",
                    TAG,
                    fileId,
                    seatIndex,
                    e.getMessage()
                );
                return null;
            }

            BodyRegionV2 region = BodyRegionV2.fromName(name);
            if (region == null) {
                LOGGER.error(
                    "{} Skipping {}: seats[{}] unknown body region '{}'",
                    TAG,
                    fileId,
                    seatIndex,
                    name
                );
                return null;
            }
            regions.add(region);
        }

        return Collections.unmodifiableSet(regions);
    }

    // ===== Feedback Parsing =====

    private static FurnitureFeedback parseFeedback(
        JsonObject obj,
        ResourceLocation fileId
    ) {
        return new FurnitureFeedback(
            parseOptionalResourceLocation(obj, "mount_sound", fileId),
            parseOptionalResourceLocation(obj, "lock_sound", fileId),
            parseOptionalResourceLocation(obj, "unlock_sound", fileId),
            parseOptionalResourceLocation(obj, "struggle_loop_sound", fileId),
            parseOptionalResourceLocation(obj, "escape_sound", fileId),
            parseOptionalResourceLocation(obj, "denied_sound", fileId)
        );
    }

    // ===== Tint Channel Parsing =====

    /**
     * Parse tint_channels with strict hex validation.
     * Returns empty map if field is absent. Returns null if any value is invalid hex.
     */
    @Nullable
    private static Map<String, Integer> parseTintChannels(
        JsonObject root,
        ResourceLocation fileId
    ) {
        if (
            !root.has("tint_channels") ||
            !root.get("tint_channels").isJsonObject()
        ) {
            return Map.of();
        }

        JsonObject channels = root.getAsJsonObject("tint_channels");
        Map<String, Integer> result = new LinkedHashMap<>();

        for (Map.Entry<String, JsonElement> entry : channels.entrySet()) {
            String hex;
            try {
                hex = entry.getValue().getAsString();
            } catch (Exception e) {
                LOGGER.error(
                    "{} Skipping {}: tint_channels '{}' value is not a string",
                    TAG,
                    fileId,
                    entry.getKey()
                );
                return null;
            }

            if (!HEX_COLOR.matcher(hex).matches()) {
                LOGGER.error(
                    "{} Skipping {}: tint_channels '{}' has invalid hex color '{}' " +
                        "(expected '#' followed by 6 hex digits)",
                    TAG,
                    fileId,
                    entry.getKey(),
                    hex
                );
                return null;
            }

            int color = Integer.parseInt(hex.substring(1), 16);
            result.put(entry.getKey(), color);
        }

        return Collections.unmodifiableMap(result);
    }

    // ===== Primitive Helpers =====

    @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 String getStringOrDefault(
        JsonObject obj,
        String key,
        String defaultValue
    ) {
        String value = getStringOrNull(obj, key);
        return (value != null && !value.isEmpty()) ? value : defaultValue;
    }

    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 float getFloatOrDefault(
        JsonObject obj,
        String key,
        float defaultValue
    ) {
        if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
        try {
            return obj.get(key).getAsFloat();
        } 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 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(
                "{} In {}: invalid ResourceLocation for '{}': '{}'",
                TAG,
                fileId,
                key,
                value
            );
        }
        return loc;
    }

    // ===== Clamping Helpers =====

    private static float clamp(float value, float min, float max) {
        return Math.max(min, Math.min(max, value));
    }

    private static int clampInt(int value, int min, int max) {
        return Math.max(min, Math.min(max, value));
    }
}
