package fr.bglands.client.protocol;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonParser;

import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;

public final class UiProtocolCodec {
    private static final int MAX_CONTROLS = 64;
    private static final int MAX_SCENE_WIDGETS = 128;
    private static final int MAX_SCENE_CANVAS_SIZE = 4_096;
    private static final Set<String> SCENE_KINDS = Set.of("button", "icon", "logo", "panel", "text");
    private static final Set<String> SCENE_ALIGNS = Set.of("left", "center", "right");
    private static final Set<String> SCENE_ASSETS = Set.of(
            "/assets/frame-full.png",
            "/assets/logo.png",
            "/assets/buttons/button_blue.png",
            "/assets/buttons/button_green.png",
            "/assets/buttons/button_purple.png",
            "/assets/buttons/button_red.png",
            "/assets/buttons/button_white.png",
            "/assets/buttons/button_yellow.png",
            "/assets/icons/book.png",
            "/assets/icons/cancel.png",
            "/assets/icons/chest.png",
            "/assets/icons/compass.png",
            "/assets/icons/friends.png",
            "/assets/icons/game.png",
            "/assets/icons/information.png",
            "/assets/icons/leader.png",
            "/assets/icons/minigames.png",
            "/assets/icons/settings.png"
    );
    private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create();

    public byte[] encode(ClientHello message) {
        JsonObject json = new JsonObject();
        json.addProperty("type", "hello");
        json.addProperty("protocolMajor", message.protocolMajor());
        json.addProperty("protocolMinor", message.protocolMinor());
        json.addProperty("modVersion", bounded(message.modVersion(), "modVersion", 1, 64));
        JsonArray capabilities = new JsonArray();
        for (String capability : message.capabilities()) {
            capabilities.add(bounded(capability, "capability", 1, 64));
        }
        json.add("capabilities", capabilities);
        return encodeObject(json);
    }

    public byte[] encode(UiAction message) {
        JsonObject json = new JsonObject();
        json.addProperty("type", "action");
        json.addProperty("instanceId", message.instanceId());
        json.addProperty("controlId", message.controlId());
        json.addProperty("token", message.token());
        json.addProperty("requestId", message.requestId());
        return encodeObject(json);
    }

    public byte[] encode(UiSubmit message) {
        JsonObject json = new JsonObject();
        json.addProperty("type", "submit");
        json.addProperty("instanceId", message.instanceId());
        json.addProperty("submitToken", message.submitToken());
        json.addProperty("requestId", message.requestId());
        JsonObject values = new JsonObject();
        if (message.values().size() > 8) {
            throw new IllegalArgumentException("A BG Lands UI form cannot submit more than 8 fields");
        }
        message.values().forEach((field, value) ->
                values.addProperty(
                        boundedFieldId(field),
                        bounded(value, "fieldValue", 0, 1_024)
                )
        );
        json.add("values", values);
        return encodeObject(json);
    }

    public ServerMessage decodeServer(byte[] payload) throws ProtocolException {
        if (payload == null || payload.length == 0) {
            throw new ProtocolException("Empty BG Lands UI payload");
        }
        if (payload.length > ProtocolConstants.MAX_PAYLOAD_BYTES) {
            throw new ProtocolException("BG Lands UI payload exceeds " + ProtocolConstants.MAX_PAYLOAD_BYTES + " bytes");
        }

        final JsonObject json;
        try {
            String text = decodeUtf8(payload);
            JsonElement element = JsonParser.parseString(text);
            if (!element.isJsonObject()) {
                throw new ProtocolException("BG Lands UI payload root must be an object");
            }
            json = element.getAsJsonObject();
        } catch (JsonParseException | IllegalStateException exception) {
            throw new ProtocolException("Malformed BG Lands UI JSON", exception);
        }

        return switch (requiredString(json, "type", 1, 32).toLowerCase(Locale.ROOT)) {
            case "probe" -> decodeProbe(json);
            case "hello_ack" -> decodeHelloAck(json);
            case "open" -> decodeOpen(json);
            case "open_form" -> decodeOpenForm(json);
            case "result" -> decodeResult(json);
            default -> throw new ProtocolException("Unsupported BG Lands UI message type");
        };
    }

    private static ServerProbe decodeProbe(JsonObject json) throws ProtocolException {
        return new ServerProbe(
                requiredInt(json, "protocolMajor", 0, 100),
                requiredInt(json, "protocolMinor", 0, 10_000),
                requiredString(json, "serverVersion", 1, 64)
        );
    }

    private static HelloAck decodeHelloAck(JsonObject json) throws ProtocolException {
        return new HelloAck(
                requiredInt(json, "protocolMajor", 0, 100),
                requiredInt(json, "protocolMinor", 0, 10_000),
                requiredString(json, "serverVersion", 1, 64),
                optionalBoolean(json, "accepted", true),
                optionalString(json, "reason", 256, "")
        );
    }

    private static OpenScreen decodeOpen(JsonObject json) throws ProtocolException {
        int major = requiredInt(json, "protocolMajor", 0, 100);
        String instanceId = requiredUuid(json, "instanceId");
        int revision = requiredInt(json, "revision", 0, Integer.MAX_VALUE);
        String route = requiredString(json, "route", 1, 160);
        String title = optionalString(json, "title", 256, "");
        String content = optionalString(json, "content", 4_096, "");
        String accent = optionalString(json, "accent", 32, "green");
        int columns = optionalInt(json, "columns", 1, 4, 2);
        String background = optionalString(json, "background", 160, "");
        boolean showExitAction = optionalBoolean(json, "showExitAction", true);

        JsonElement controlsElement = json.get("controls");
        if (controlsElement == null || !controlsElement.isJsonArray()) {
            throw new ProtocolException("controls must be an array");
        }
        JsonArray controlsJson = controlsElement.getAsJsonArray();
        if (controlsJson.size() > MAX_CONTROLS) {
            throw new ProtocolException("Too many controls (maximum " + MAX_CONTROLS + ")");
        }
        List<UiControl> controls = new ArrayList<>(controlsJson.size());
        var controlIds = new HashSet<String>();
        for (JsonElement controlElement : controlsJson) {
            if (!controlElement.isJsonObject()) {
                throw new ProtocolException("Each control must be an object");
            }
            JsonObject control = controlElement.getAsJsonObject();
            String controlId = requiredString(control, "id", 1, 96);
            if (!controlId.matches("c(?:0|[1-9][0-9]{0,2})")) {
                throw new ProtocolException("Invalid control id: " + controlId);
            }
            if (!controlIds.add(controlId)) {
                throw new ProtocolException("Duplicate control id: " + controlId);
            }
            controls.add(new UiControl(
                    controlId,
                    requiredString(control, "label", 1, 160),
                    optionalString(control, "description", 512, ""),
                    optionalString(control, "icon", 160, ""),
                    requiredUuid(control, "token"),
                    optionalNullableInt(control, "x", 0, 2_048),
                    optionalNullableInt(control, "y", 0, 2_048),
                    optionalNullableInt(control, "width", 1, 1_024),
                    optionalNullableInt(control, "height", 1, 1_024),
                    optionalString(control, "color", 32, "")
            ));
        }
        UiScene scene = json.has("scene") && !json.get("scene").isJsonNull()
                ? decodeScene(json.get("scene"), controlIds)
                : null;
        return new OpenScreen(
                major,
                instanceId,
                revision,
                route,
                title,
                content,
                accent,
                columns,
                background,
                showExitAction,
                controls,
                scene
        );
    }

    private static UiScene decodeScene(JsonElement sceneElement, Set<String> controlIds) throws ProtocolException {
        if (!sceneElement.isJsonObject()) {
            throw new ProtocolException("scene must be an object");
        }
        JsonObject scene = sceneElement.getAsJsonObject();
        requireOnly(scene, Set.of("schemaVersion", "canvas", "widgets"), "scene");
        int schemaVersion = requiredInt(scene, "schemaVersion", 1, 1);
        JsonElement canvasElement = scene.get("canvas");
        if (canvasElement == null || !canvasElement.isJsonObject()) {
            throw new ProtocolException("scene.canvas must be an object");
        }
        JsonObject canvas = canvasElement.getAsJsonObject();
        requireOnly(canvas, Set.of("width", "height", "backgroundAsset"), "scene.canvas");
        int canvasWidth = requiredInt(canvas, "width", 1, MAX_SCENE_CANVAS_SIZE);
        int canvasHeight = requiredInt(canvas, "height", 1, MAX_SCENE_CANVAS_SIZE);
        String backgroundAsset = requiredAsset(canvas, "backgroundAsset");

        JsonElement widgetsElement = scene.get("widgets");
        if (widgetsElement == null || !widgetsElement.isJsonArray()) {
            throw new ProtocolException("scene.widgets must be an array");
        }
        JsonArray widgetsJson = widgetsElement.getAsJsonArray();
        if (widgetsJson.size() > MAX_SCENE_WIDGETS) {
            throw new ProtocolException("Too many scene widgets");
        }
        List<UiSceneWidget> widgets = new ArrayList<>(widgetsJson.size());
        Set<String> widgetIds = new HashSet<>();
        Set<String> sceneControlIds = new HashSet<>();
        for (int index = 0; index < widgetsJson.size(); index++) {
            JsonElement widgetElement = widgetsJson.get(index);
            if (!widgetElement.isJsonObject()) {
                throw new ProtocolException("Each scene widget must be an object");
            }
            JsonObject widget = widgetElement.getAsJsonObject();
            requireOnly(widget, Set.of(
                    "id", "kind", "x", "y", "width", "height", "z", "asset", "label", "binding",
                    "color", "fontSize", "align", "controlId"
            ), "scene.widgets[" + index + "]");
            String id = requiredString(widget, "id", 1, 96);
            if (!id.matches("[A-Za-z0-9._/-]+") || !widgetIds.add(id)) {
                throw new ProtocolException("Invalid or duplicate scene widget id: " + id);
            }
            String kind = requiredString(widget, "kind", 1, 16).toLowerCase(Locale.ROOT);
            if (!SCENE_KINDS.contains(kind)) {
                throw new ProtocolException("Unsupported scene widget kind: " + kind);
            }
            int x = requiredInt(widget, "x", 0, canvasWidth);
            int y = requiredInt(widget, "y", 0, canvasHeight);
            int width = requiredInt(widget, "width", 1, canvasWidth);
            int height = requiredInt(widget, "height", 1, canvasHeight);
            if ((long) x + width > canvasWidth || (long) y + height > canvasHeight) {
                throw new ProtocolException("Scene widget exceeds the canvas: " + id);
            }
            String asset = widget.has("asset") && !widget.get("asset").isJsonNull()
                    ? requiredAsset(widget, "asset")
                    : "";
            if ((kind.equals("button") || kind.equals("icon") || kind.equals("logo") || kind.equals("panel"))
                    && asset.isEmpty()) {
                throw new ProtocolException("Scene widget requires a local asset: " + id);
            }
            if (kind.equals("text") && !asset.isEmpty()) {
                throw new ProtocolException("Text scene widgets cannot declare an asset");
            }
            String align = optionalString(widget, "align", 16, "left").toLowerCase(Locale.ROOT);
            if (!SCENE_ALIGNS.contains(align)) {
                throw new ProtocolException("Unsupported scene text alignment: " + align);
            }
            String color = optionalString(widget, "color", 16, "");
            if (!color.isEmpty() && !color.matches("#[0-9A-Fa-f]{6}")) {
                throw new ProtocolException("Scene colour must be #RRGGBB");
            }
            String controlId = optionalString(widget, "controlId", 96, "");
            if (kind.equals("button")) {
                if (!controlIds.contains(controlId) || !sceneControlIds.add(controlId)) {
                    throw new ProtocolException("Scene button references an invalid or duplicate control");
                }
            } else if (!controlId.isEmpty()) {
                throw new ProtocolException("Only scene buttons can reference a control");
            }
            widgets.add(new UiSceneWidget(
                    id,
                    kind,
                    x,
                    y,
                    width,
                    height,
                    optionalInt(widget, "z", -1_024, 1_024, 0),
                    asset,
                    optionalString(widget, "label", 256, ""),
                    optionalString(widget, "binding", 4_096, ""),
                    color,
                    optionalInt(widget, "fontSize", 1, 64, 6),
                    align,
                    controlId
            ));
        }
        long buttonCount = widgets.stream().filter(widget -> widget.kind().equals("button")).count();
        if (buttonCount != controlIds.size()) {
            throw new ProtocolException("Authored scene must bind every server control exactly once");
        }
        int firstButtonZ = widgets.stream().filter(widget -> widget.kind().equals("button"))
                .mapToInt(UiSceneWidget::z).min().orElse(Integer.MAX_VALUE);
        int lastDecorationZ = widgets.stream().filter(widget -> !widget.kind().equals("button"))
                .mapToInt(UiSceneWidget::z).max().orElse(Integer.MIN_VALUE);
        if (lastDecorationZ > firstButtonZ) {
            throw new ProtocolException("Authored decoration z cannot cover an interactive control");
        }
        return new UiScene(schemaVersion, new UiSceneCanvas(canvasWidth, canvasHeight, backgroundAsset), widgets);
    }

    private static OpenForm decodeOpenForm(JsonObject json) throws ProtocolException {
        int major = optionalInt(json, "protocolMajor", 0, 100, ProtocolConstants.PROTOCOL_MAJOR);
        String instanceId = requiredUuid(json, "instanceId");
        String route = requiredString(json, "route", 1, 160);
        String title = optionalString(json, "title", 256, "");
        String instruction = optionalString(json, "instruction", 1_024, "");
        String submitToken = requiredUuid(json, "submitToken");

        JsonElement fieldsElement = json.get("fields");
        if (fieldsElement == null || !fieldsElement.isJsonArray()) {
            throw new ProtocolException("fields must be an array");
        }
        JsonArray fieldsJson = fieldsElement.getAsJsonArray();
        if (fieldsJson.size() < 1 || fieldsJson.size() > 8) {
            throw new ProtocolException("fields must contain between 1 and 8 entries");
        }
        List<FormField> fields = new ArrayList<>(fieldsJson.size());
        var fieldIds = new HashSet<String>();
        for (JsonElement fieldElement : fieldsJson) {
            if (!fieldElement.isJsonObject()) {
                throw new ProtocolException("Each field must be an object");
            }
            JsonObject field = fieldElement.getAsJsonObject();
            String fieldId = requiredString(field, "id", 1, 64);
            if (!fieldId.matches("f(?:0|[1-9][0-9]?)")) {
                throw new ProtocolException("Invalid form field id: " + fieldId);
            }
            if (!fieldIds.add(fieldId)) {
                throw new ProtocolException("Duplicate form field id: " + fieldId);
            }
            fields.add(new FormField(
                    fieldId,
                    requiredString(field, "label", 1, 128),
                    optionalString(field, "placeholder", 256, ""),
                    optionalInt(field, "maxLength", 1, 1_024, 64)
            ));
        }
        return new OpenForm(major, instanceId, route, title, instruction, fields, submitToken);
    }

    private static UiResult decodeResult(JsonObject json) throws ProtocolException {
        return new UiResult(
                requiredUuid(json, "requestId"),
                requiredString(json, "status", 1, 32).toLowerCase(Locale.ROOT),
                optionalString(json, "message", 512, "")
        );
    }

    private static byte[] encodeObject(JsonObject json) {
        byte[] bytes = GSON.toJson(json).getBytes(StandardCharsets.UTF_8);
        if (bytes.length > ProtocolConstants.MAX_PAYLOAD_BYTES) {
            throw new IllegalArgumentException("BG Lands UI payload exceeds " + ProtocolConstants.MAX_PAYLOAD_BYTES + " bytes");
        }
        return bytes;
    }

    private static String decodeUtf8(byte[] payload) throws ProtocolException {
        try {
            return StandardCharsets.UTF_8.newDecoder()
                    .onMalformedInput(CodingErrorAction.REPORT)
                    .onUnmappableCharacter(CodingErrorAction.REPORT)
                    .decode(ByteBuffer.wrap(payload))
                    .toString();
        } catch (CharacterCodingException exception) {
            throw new ProtocolException("BG Lands UI payload is not valid UTF-8", exception);
        }
    }

    private static String requiredUuid(JsonObject json, String name) throws ProtocolException {
        String value = requiredString(json, name, 36, 36);
        try {
            UUID.fromString(value);
            return value;
        } catch (IllegalArgumentException exception) {
            throw new ProtocolException(name + " must be a UUID", exception);
        }
    }

    private static String requiredString(JsonObject json, String name, int min, int max) throws ProtocolException {
        JsonElement value = json.get(name);
        if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) {
            throw new ProtocolException(name + " must be a string");
        }
        try {
            return bounded(value.getAsString(), name, min, max);
        } catch (IllegalArgumentException exception) {
            throw new ProtocolException(exception.getMessage(), exception);
        }
    }

    private static String optionalString(JsonObject json, String name, int max, String fallback) throws ProtocolException {
        JsonElement value = json.get(name);
        if (value == null || value.isJsonNull()) {
            return fallback;
        }
        if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) {
            throw new ProtocolException(name + " must be a string");
        }
        String string = value.getAsString();
        if (string.length() > max) {
            throw new ProtocolException(name + " is too long");
        }
        return string;
    }

    private static String requiredAsset(JsonObject json, String name) throws ProtocolException {
        String value = requiredString(json, name, 1, 256);
        if (!SCENE_ASSETS.contains(value)) {
            throw new ProtocolException(name + " is not a packaged BG Lands asset");
        }
        return value;
    }

    private static void requireOnly(JsonObject json, Set<String> allowed, String path) throws ProtocolException {
        for (String key : json.keySet()) {
            if (!allowed.contains(key)) {
                throw new ProtocolException(path + " contains unsupported field: " + key);
            }
        }
    }

    private static int requiredInt(JsonObject json, String name, int min, int max) throws ProtocolException {
        JsonElement value = json.get(name);
        if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) {
            throw new ProtocolException(name + " must be an integer");
        }
        try {
            int number = value.getAsInt();
            if (number < min || number > max) {
                throw new ProtocolException(name + " is outside the accepted range");
            }
            return number;
        } catch (NumberFormatException exception) {
            throw new ProtocolException(name + " must be an integer", exception);
        }
    }

    private static int optionalInt(JsonObject json, String name, int min, int max, int fallback) throws ProtocolException {
        return json.has(name) && !json.get(name).isJsonNull() ? requiredInt(json, name, min, max) : fallback;
    }

    private static Integer optionalNullableInt(JsonObject json, String name, int min, int max) throws ProtocolException {
        return json.has(name) && !json.get(name).isJsonNull() ? requiredInt(json, name, min, max) : null;
    }

    private static boolean optionalBoolean(JsonObject json, String name, boolean fallback) throws ProtocolException {
        JsonElement value = json.get(name);
        if (value == null || value.isJsonNull()) {
            return fallback;
        }
        if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) {
            throw new ProtocolException(name + " must be a boolean");
        }
        return value.getAsBoolean();
    }

    private static String bounded(String value, String name, int min, int max) {
        if (value == null || value.length() < min || value.length() > max) {
            throw new IllegalArgumentException(name + " length must be in [" + min + ", " + max + "]");
        }
        return value;
    }

    private static String boundedFieldId(String value) {
        String id = bounded(value, "fieldId", 1, 64);
        if (!id.matches("f(?:0|[1-9][0-9]?)")) {
            throw new IllegalArgumentException("Invalid BG Lands form field id");
        }
        return id;
    }
}
