package com.grigowashere.aismap.ble.hub;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import org.json.JSONArray;
import org.json.JSONObject;
import org.msgpack.core.MessagePack;
import org.msgpack.core.MessageUnpacker;
import org.msgpack.value.Value;
import org.msgpack.value.ValueType;

import java.nio.charset.StandardCharsets;
import java.util.Map;

/**
 * Decodes a reassembled AIS Hub message payload into a {@link JSONObject},
 * regardless of whether the server sent it as JSON (proto 0x01) or
 * MessagePack (proto 0x02). All downstream code in this app consumes
 * {@link JSONObject}, so converting MessagePack → JSONObject here keeps the
 * rest of the codebase unchanged.
 *
 * <p>Why convert to JSONObject instead of surfacing the raw msgpack Value?
 * The existing consumers ({@code onDataJson}) and all the mappers in
 * {@link AisHubJsonMapper} are written against {@code org.json.*}. Rewriting
 * them would be a large blast radius for a wire-format optimization that the
 * consumer should be agnostic to. The conversion is O(payload size) and runs
 * off the BLE callback thread (see {@code AisHubGattClient#dataExecutor}).
 */
public final class AisHubPayloadCodec {

    private AisHubPayloadCodec() {}

    /**
     * Decode a reassembled payload into a JSONObject.
     *
     * @param payload  raw reassembled bytes (never null when called)
     * @param protoVer one of {@link AisHubConstants#PROTO_VERSION_JSON} or
     *                 {@link AisHubConstants#PROTO_VERSION_MSGPACK}
     * @return the decoded root object, or null if the payload is malformed
     *         or the top-level value is not an object/map (we never expect
     *         a bare array or scalar at the root in this protocol).
     */
    @Nullable
    public static JSONObject decodeToJsonObject(@NonNull byte[] payload, int protoVer) throws Exception {
        if (protoVer == AisHubConstants.PROTO_VERSION_MSGPACK) {
            return decodeMsgpack(payload);
        }
        // Default / 0x01: JSON UTF-8. Unknown versions are treated as JSON
        // rather than silently dropped, to be forward-compatible with future
        // server versions that still emit JSON-shaped payloads.
        String jsonStr = new String(payload, StandardCharsets.UTF_8);
        return new JSONObject(jsonStr);
    }

    /**
     * Same as {@link #decodeToJsonObject(byte[], int)} but takes a pre-built
     * JSON string (used by the legacy fast path where the assembler already
     * produced the UTF-8 string).
     */
    @NonNull
    public static JSONObject decodeJsonString(@NonNull String json) throws Exception {
        return new JSONObject(json);
    }

    @Nullable
    private static JSONObject decodeMsgpack(@NonNull byte[] payload) throws Exception {
        try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(payload)) {
            if (!unpacker.hasNext()) return null;
            Value root = unpacker.unpackValue();
            if (root == null || !root.getValueType().isMapType()) {
                return null;
            }
            return (JSONObject) toJson(root);
        }
    }

    /**
     * Recursively convert a MessagePack {@link Value} to an {@code org.json.*}
     * tree. Maps become {@link JSONObject}, arrays become {@link JSONArray},
     * binary blobs become Base64 strings (we don't expect any in this
     * protocol, but they must not crash the decoder).
     */
    private static Object toJson(@NonNull Value v) throws Exception {
        ValueType t = v.getValueType();
        switch (t) {
            case NIL:
                return JSONObject.NULL;
            case BOOLEAN:
                return v.asBooleanValue().getBoolean();
            case INTEGER:
                // Prefer long to preserve 64-bit MMSI values etc.
                return v.asIntegerValue().toLong();
            case FLOAT:
                return v.asFloatValue().toDouble();
            case STRING:
                return v.asStringValue().asString();
            case BINARY:
                // Shouldn't occur on this wire format; stringify defensively.
                return android.util.Base64.encodeToString(
                        v.asBinaryValue().asByteArray(), android.util.Base64.NO_WRAP);
            case ARRAY: {
                JSONArray arr = new JSONArray();
                for (Value item : v.asArrayValue()) {
                    arr.put(toJson(item));
                }
                return arr;
            }
            case MAP: {
                JSONObject obj = new JSONObject();
                for (Map.Entry<Value, Value> e : v.asMapValue().entrySet()) {
                    // Keys in our protocol are always strings. If a non-string
                    // key sneaks in, stringify it so we don't silently drop data.
                    String key = e.getKey().getValueType() == ValueType.STRING
                            ? e.getKey().asStringValue().asString()
                            : e.getKey().toJson();
                    obj.put(key, toJson(e.getValue()));
                }
                return obj;
            }
            case EXTENSION:
            default:
                // Unknown / extension types: stringify to avoid crashing.
                return v.toJson();
        }
    }
}
