package com.useinsider.react;

import android.util.Log;

import androidx.annotation.Nullable;

import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.ReadableMapKeySetIterator;
import com.facebook.react.bridge.ReadableType;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.bridge.WritableNativeArray;
import com.facebook.react.bridge.WritableNativeMap;
import com.useinsider.insider.AppCardsException;
import com.useinsider.insider.ContentOptimizerDataType;
import com.useinsider.insider.Insider;
import com.useinsider.insider.InsiderEvent;
import com.useinsider.insider.InsiderGender;
import com.useinsider.insider.InsiderProduct;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

public class RNUtils {

    // logcat tag for every warning the bridge raises; shared so the SDK's output cannot split in two.
    static final String LOG_TAG = "RNInsider";

    // The core stores one object inside an object and drops anything deeper; mirrored from JS.
    static final int MAX_OBJECT_NESTING_DEPTH = 2;

    // Identifier keys sent by InsiderIdentifier.js; every other key becomes a custom identifier.
    static final String ADD_EMAIL = "addEmail";
    static final String ADD_PHONE_NUMBER = "addPhoneNumber";
    static final String ADD_USER_ID = "addUserID";

    static InsiderGender toInsiderGender(int gender) {
        switch (gender) {
            case 0:
                return InsiderGender.MALE;
            case 1:
                return InsiderGender.FEMALE;
            default:
                return InsiderGender.OTHER;
        }
    }

    // Null for an unknown dataType, which is reported; callers answer the default without the SDK.
    @Nullable
    static ContentOptimizerDataType toContentOptimizerDataType(int dataType) {
        ContentOptimizerDataType[] types = ContentOptimizerDataType.values();
        if (dataType < 0 || dataType >= types.length) {
            Insider.Instance.putException(new IllegalArgumentException("Unknown ContentOptimizerDataType " + dataType + "; answering the default without calling the SDK"));
            return null;
        }
        return types[dataType];
    }

    public static WritableMap convertMapToWritableMap(HashMap<String, String> map) {
        WritableMap writableMap = new WritableNativeMap();
        try {
            for (String key : map.keySet()) {
                writableMap.putString(key, map.get(key));
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        return writableMap;
    }

    /**
     * Recursively converts a {@code Map<String, Object>} coming from the native SDK into a
     * {@link WritableMap} for the React Native bridge.
     *
     * <p>Unlike {@link #convertMapToWritableMap(HashMap)} — which only accepts
     * {@code HashMap<String, String>} — this handles the heterogeneous value types the SDK
     * emits for recorded-event parameters: nested maps, {@link List}s and arrays (including
     * the {@code String[]} / {@code Number[]} produced by
     * {@code addParameterWithStringArray} / {@code addParameterWithNumericArray}), booleans,
     * integral and floating point numbers, strings, dates and nulls.
     *
     * <p>Following the house style, a failure is reported through
     * {@code Insider.Instance.putException} and the partial result is returned rather than
     * throwing back into the SDK's callback thread.
     */
    public static WritableMap convertObjectMapToWritableMap(Map<String, Object> map) {
        WritableMap writableMap = new WritableNativeMap();
        try {
            if (map == null) {
                return writableMap;
            }

            for (Map.Entry<String, Object> entry : map.entrySet()) {
                String key = entry.getKey();
                if (key == null) continue;

                putObjectIntoWritableMap(writableMap, key, entry.getValue());
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        return writableMap;
    }

    /**
     * Recursively converts a {@link List} or Java array from the native SDK into a
     * {@link WritableArray}. Element type dispatch matches
     * {@link #convertObjectMapToWritableMap(Map)}.
     */
    public static WritableArray convertObjectListToWritableArray(Object list) {
        WritableArray writableArray = new WritableNativeArray();
        try {
            if (list == null) {
                return writableArray;
            }

            if (list instanceof List) {
                for (Object value : (List<?>) list) {
                    pushObjectIntoWritableArray(writableArray, value);
                }
            } else if (list.getClass().isArray()) {
                int length = Array.getLength(list);
                for (int i = 0; i < length; i++) {
                    pushObjectIntoWritableArray(writableArray, Array.get(list, i));
                }
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        return writableArray;
    }

    @SuppressWarnings("unchecked")
    private static void putObjectIntoWritableMap(WritableMap map, String key, Object value) {
        if (value == null) {
            map.putNull(key);
        } else if (value instanceof Map) {
            map.putMap(key, convertObjectMapToWritableMap((Map<String, Object>) value));
        } else if (value instanceof List || value.getClass().isArray()) {
            map.putArray(key, convertObjectListToWritableArray(value));
        } else if (value instanceof Boolean) {
            map.putBoolean(key, (Boolean) value);
        } else if (value instanceof Integer || value instanceof Short || value instanceof Byte) {
            map.putInt(key, ((Number) value).intValue());
        } else if (value instanceof Number) {
            // Long included: the bridge has no 64-bit integer type, so widen to double.
            map.putDouble(key, ((Number) value).doubleValue());
        } else if (value instanceof String) {
            map.putString(key, (String) value);
        } else if (value instanceof Date) {
            map.putDouble(key, ((Date) value).getTime());
        } else {
            map.putString(key, value.toString());
        }
    }

    @SuppressWarnings("unchecked")
    private static void pushObjectIntoWritableArray(WritableArray array, Object value) {
        if (value == null) {
            array.pushNull();
        } else if (value instanceof Map) {
            array.pushMap(convertObjectMapToWritableMap((Map<String, Object>) value));
        } else if (value instanceof List || value.getClass().isArray()) {
            array.pushArray(convertObjectListToWritableArray(value));
        } else if (value instanceof Boolean) {
            array.pushBoolean((Boolean) value);
        } else if (value instanceof Integer || value instanceof Short || value instanceof Byte) {
            array.pushInt(((Number) value).intValue());
        } else if (value instanceof Number) {
            array.pushDouble(((Number) value).doubleValue());
        } else if (value instanceof String) {
            array.pushString((String) value);
        } else if (value instanceof Date) {
            array.pushDouble(((Date) value).getTime());
        } else {
            array.pushString(value.toString());
        }
    }

    public static WritableArray convertJSONArrayToArray(JSONArray jsonArray) {
        WritableArray array = new WritableNativeArray();
        try {
            for (int i = 0; i < jsonArray.length(); i++) {
                Object value = jsonArray.get(i);
                if (value instanceof JSONObject) {
                    array.pushMap(convertJSONObjectToMap((JSONObject) value));
                } else if (value instanceof JSONArray) {
                    array.pushArray(convertJSONArrayToArray((JSONArray) value));
                } else if (value instanceof Boolean) {
                    array.pushBoolean((Boolean) value);
                } else if (value instanceof Integer) {
                    array.pushInt((Integer) value);
                } else if (value instanceof Double) {
                    array.pushDouble((Double) value);
                } else if (value instanceof String) {
                    array.pushString((String) value);
                } else {
                    array.pushString(value.toString());
                }
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        return array;
    }

    public static WritableMap convertJSONObjectToMap(JSONObject jsonObject) {
        WritableMap map = new WritableNativeMap();
        try {
            Iterator<String> iterator = jsonObject.keys();
            while (iterator.hasNext()) {
                String key = iterator.next();
                Object value = jsonObject.get(key);
                if (value instanceof JSONObject) {
                    map.putMap(key, convertJSONObjectToMap((JSONObject) value));
                } else if (value instanceof JSONArray) {
                    map.putArray(key, convertJSONArrayToArray((JSONArray) value));
                } else if (value instanceof Boolean) {
                    map.putBoolean(key, (Boolean) value);
                } else if (value instanceof Integer) {
                    map.putInt(key, (Integer) value);
                } else if (value instanceof Double) {
                    map.putDouble(key, (Double) value);
                } else if (value instanceof String) {
                    map.putString(key, (String) value);
                } else {
                    map.putString(key, value.toString());
                }
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        return map;
    }

    public static HashMap<String, Object> convertReadableMapToMap(ReadableMap readableMap) {
        HashMap<String, Object> map = new HashMap<>();
        try {
            ReadableMapKeySetIterator iterator = readableMap.keySetIterator();
            while (iterator.hasNextKey()) {
                String key = iterator.nextKey();
                ReadableType type = readableMap.getType(key);
                switch (type) {
                    case Boolean:
                        map.put(key, readableMap.getBoolean(key));
                        break;
                    case Number:
                        map.put(key, readableMap.getDouble(key));
                        break;
                    case String:
                        map.put(key, readableMap.getString(key));
                        break;
                    case Map:
                        map.put(key, convertReadableMapToMap(readableMap.getMap(key)));
                        break;
                    case Array:
                        ReadableArray array = readableMap.getArray(key);
                        if (isConvertibleToNumericArray(array)) {
                            map.put(key, convertReadableArrayToNumericArray(array));
                        } else {
                            map.put(key, convertReadableArrayToStringArray(array));
                        }
                        break;
                }
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        return map;
    }

    public static String[] convertReadableArrayToStringArray(ReadableArray readableArray) {
        ArrayList<String> arrayList = new ArrayList<>();
        try {
            for (int i = 0; i < readableArray.size(); i++) {
                ReadableType type = readableArray.getType(i);
                if (type == ReadableType.String) {
                    arrayList.add(readableArray.getString(i));
                }
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        return arrayList.toArray(new String[0]);
    }

    public static Number[] convertReadableArrayToNumericArray(ReadableArray readableArray) {
        ArrayList<Number> arrayList = new ArrayList<>();
        try {
            for (int i = 0; i < readableArray.size(); i++) {
                ReadableType type = readableArray.getType(i);
                if (type == ReadableType.Number) {
                    arrayList.add(readableArray.getDouble(i));
                }
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        return arrayList.toArray(new Number[0]);
    }

    public static HashMap<String, Object> fixIntegerAttributesInMap(HashMap<String, Object> map) {
        try {
            final String stock = "stock";
            final String quantity = "quantity";
            if (map.containsKey(stock)) {
                map.put(stock, ((Double) map.get(stock)).intValue());
            }
            if (map.containsKey(quantity)) {
                map.put(quantity, ((Double) map.get(quantity)).intValue());
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        return map;
    }

    // Keeps each whole-number element an Integer or a Long, the int32-boundary rule the scalar
    // "integer" case applies. convertReadableArrayToNumericArray stays Double[] for its own callers.
    private static Number[] convertReadableArrayToPreciseNumericArray(ReadableArray readableArray) {
        ArrayList<Number> arrayList = new ArrayList<>();
        try {
            for (int i = 0; i < readableArray.size(); i++) {
                ReadableType type = readableArray.getType(i);
                if (type != ReadableType.Number) continue;

                double number = readableArray.getDouble(i);
                if (Double.isFinite(number) && number == Math.rint(number)) {
                    arrayList.add(toPreciseNumberOrDouble(number));
                } else {
                    arrayList.add(number);
                }
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        return arrayList.toArray(new Number[0]);
    }

    /** A whole number too large for a long saturates on an unchecked cast, silently replacing the
     *  partner's value; only a long that converts back to the same double may narrow. */
    private static Number toPreciseNumberOrDouble(double number) {
        if (number >= Integer.MIN_VALUE && number <= Integer.MAX_VALUE) {
            return (int) number;
        }

        // 2^63 is the first whole number a long cannot hold, and the cast saturates rather than
        // failing - so the round trip alone accepts it, `(double) Long.MAX_VALUE` rounding back to it.
        if (number >= 9.223372036854776E18 || number < -9.223372036854776E18) {
            return number;
        }

        long asLong = (long) number;

        return (double) asLong == number ? (Number) asLong : (Number) number;
    }

    private static boolean isConvertibleToNumericArray(ReadableArray readableArray) {
        try {
            for (int i = 0; i < readableArray.size(); i++) {
                ReadableType type = readableArray.getType(i);
                if (type != ReadableType.Number) {
                    return false;
                }
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
            return false;
        }
        return true;
    }

    static InsiderEvent parseEvent(String eventName, ReadableArray parameters) {
        InsiderEvent event = Insider.Instance.tagEvent(eventName);

        for (int i = 0; i < parameters.size(); i++) {
            // The core never drops an event because one parameter is bad - `InsiderEvent.java` skips
            // the entry and returns `this` - so a mistyped entry must cost only itself here too.
            try {
                ReadableMap parameter = parameters.getMap(i);
                if (parameter == null) continue;

                String type = parameter.getString("type");
                String key = parameter.getString("key");
                if (type == null || key == null) continue;

                switch (type) {
                    case "string":
                        String stringValue = parameter.getString("value");
                        event.addParameterWithString(key, stringValue);
                        break;
                    case "integer":
                        // A fractional value tagged `integer` is a mistagged or hand-built payload: the JS
                        // serializer only ever tags a whole value that way. Truncating it to 1 would send a
                        // value the partner never wrote, so skip the parameter instead.
                        if (!isWholeNumber(parameter.getDouble("value"))) break;
                        int intValue = parameter.getInt("value");
                        event.addParameterWithInt(key, intValue);
                        break;
                    case "double":
                        double doubleValue = parameter.getDouble("value");
                        event.addParameterWithDouble(key, doubleValue);
                        break;
                    case "boolean":
                        boolean booleanValue = parameter.getBoolean("value");
                        event.addParameterWithBoolean(key, booleanValue);
                        break;
                    case "date":
                        // Epoch time crosses as a String because the bridge has no long. An unparseable
                        // value must cost only this parameter, never the whole event.
                        long epochValue;
                        try {
                            epochValue = Long.parseLong(parameter.getString("value"));
                        } catch (NumberFormatException exception) {
                            break;
                        }
                        // A parseable epoch outside the JavaScript Date range cannot come from
                        // Date.prototype.getTime(); formatting it yields an absurd year-292-million
                        // timestamp, so skip the parameter instead.
                        if (!isSendableEpochMillis(epochValue)) break;
                        Date dateValue = new Date(epochValue);
                        event.addParameterWithDate(key, dateValue);
                        break;
                    case "strings":
                        ReadableArray stringsArray = parameter.getArray("value");
                        String[] stringsValue = RNUtils.convertReadableArrayToStringArray(stringsArray);
                        event.addParameterWithStringArray(key, stringsValue);
                        break;
                    case "numbers":
                        ReadableArray numbersArray = parameter.getArray("value");
                        Number[] numbersValue = RNUtils.convertReadableArrayToNumericArray(numbersArray);
                        event.addParameterWithNumericArray(key, numbersValue);
                        break;
                    case "objects":
                        ReadableArray objectsArray = parameter.getArray("value");
                        // Skipped rather than forwarded as an empty list: forwarding [] built
                        // different events from one hand-built payload. Only a null value lands here -
                        // a non-array one throws into the per-entry catch, which reports a cast error
                        // instead, so the parity with iOS's single report holds for null alone.
                        if (objectsArray == null) {
                            reportSkippedParameter(key, "objects parameter value is not an array");
                            break;
                        }
                        event.addParameterWithObjectArray(key, RNUtils.parseCustomParametersList(objectsArray));
                        break;
                    default:
                        // Skip + report, rather than throwing into the catch below: an unknown type is
                        // one parameter's problem, and using an exception as control flow inside its
                        // own handler would only obscure that.
                        reportSkippedParameter(key, "unknown parameter type " + type);
                        break;
                }
            } catch (Exception exception) {
                Insider.Instance.putException(exception);
            }
        }

        return event;
    }

    static InsiderProduct parseProduct(ReadableMap requiredFields, ReadableMap optionalFields, ReadableArray customParameters) {
        // apply required fields
        String productId = requiredFields.getString("product_id");
        String name = requiredFields.getString("name");
        ReadableArray taxonomyReadableArray = requiredFields.getArray("taxonomy");
        String[] taxonomy = RNUtils.convertReadableArrayToStringArray(taxonomyReadableArray);
        String imageURL = requiredFields.getString("image_url");
        double price = requiredFields.getDouble("price");
        String currency = requiredFields.getString("currency");
        InsiderProduct product = Insider.Instance.createNewProduct(productId, name, taxonomy, imageURL, price, currency);

        // apply optional fields
        Iterator<Map.Entry<String, Object>> optionalIterator = optionalFields.getEntryIterator();
        while (optionalIterator.hasNext()) {
            // Same per-parameter containment as parseEvent. Every branch below casts
            // the bridge value without checking it, so a single mistyped optional field would
            // throw ClassCastException out of the @ReactMethod and lose the product.
            try {
                Map.Entry<String, Object> parameter = optionalIterator.next();
                String key = parameter.getKey();
                Object value = parameter.getValue();

                switch (key) {
                    case "color":
                        product.setColor((String) value);
                        break;
                    case "voucher_name":
                        product.setVoucherName((String) value);
                        break;
                    case "promotion_name":
                        product.setPromotionName((String) value);
                        break;
                    case "size":
                        product.setSize((String) value);
                        break;
                    case "sale_price":
                        product.setSalePrice((double) value);
                        break;
                    case "shipping_cost":
                        product.setShippingCost((double) value);
                        break;
                    case "voucher_discount":
                        product.setVoucherDiscount((double) value);
                        break;
                    case "promotion_discount":
                        product.setPromotionDiscount((double) value);
                        break;
                    case "stock":
                        Double stockNumber = (Double) value;
                        product.setStock(stockNumber.intValue());
                        break;
                    case "quantity":
                        Double quantityNumber = (Double) value;
                        product.setQuantity(quantityNumber.intValue());
                        break;
                    case "group_code":
                        product.setGroupCode((String) value);
                        break;
                    case "brand":
                        product.setBrand((String) value);
                        break;
                    case "sku":
                        product.setSku((String) value);
                        break;
                    case "gender":
                        product.setGender((String) value);
                        break;
                    case "multipack":
                        product.setMultipack((String) value);
                        break;
                    case "product_type":
                        product.setProductType((String) value);
                        break;
                    case "gtin":
                        product.setGtin((String) value);
                        break;
                    case "description":
                        product.setDescription((String) value);
                        break;
                    case "tags":
                        ReadableArray tagsReadableArray = (ReadableArray) value;
                        String[] tagsArray = RNUtils.convertReadableArrayToStringArray(tagsReadableArray);
                        product.setTags(tagsArray);
                        break;
                    case "in_stock":
                        product.setInStock((boolean) value);
                        break;
                    case "product_url":
                        product.setProductURL((String) value);
                        break;
                }
            } catch (Exception exception) {
                Insider.Instance.putException(exception);
            }
        }

        // apply custom parameters
        for (int i = 0; i < customParameters.size(); i++) {
            // Same per-parameter containment as parseEvent.
            try {
                ReadableMap parameter = customParameters.getMap(i);
                if (parameter == null) continue;

                String type = parameter.getString("type");
                String key = parameter.getString("key");
                if (type == null || key == null) continue;

                switch (type) {
                    case "string":
                        String stringValue = parameter.getString("value");
                        product.setCustomAttributeWithString(key, stringValue);
                        break;
                    case "integer":
                        // Same wholeness guard as parseEvent above.
                        if (!isWholeNumber(parameter.getDouble("value"))) break;
                        int intValue = parameter.getInt("value");
                        product.setCustomAttributeWithInt(key, intValue);
                        break;
                    case "double":
                        double doubleValue = parameter.getDouble("value");
                        product.setCustomAttributeWithDouble(key, doubleValue);
                        break;
                    case "boolean":
                        boolean booleanValue = parameter.getBoolean("value");
                        product.setCustomAttributeWithBoolean(key, booleanValue);
                        break;
                    case "date":
                        // React Native's bridge does not support long type directly, so we use String to pass epoch time.
                        // Same parse containment as parseEvent above.
                        long epochValue;
                        try {
                            epochValue = Long.parseLong(parameter.getString("value"));
                        } catch (NumberFormatException exception) {
                            break;
                        }
                        // Same range guard as parseEvent above.
                        if (!isSendableEpochMillis(epochValue)) break;
                        Date dateValue = new Date(epochValue);
                        product.setCustomAttributeWithDate(key, dateValue);
                        break;
                    case "strings":
                        ReadableArray stringsArray = parameter.getArray("value");
                        String[] stringsValue = RNUtils.convertReadableArrayToStringArray(stringsArray);
                        product.setCustomAttributeWithStringArray(key, stringsValue);
                        break;
                    case "numbers":
                        ReadableArray numbersArray = parameter.getArray("value");
                        Number[] numbersValue = RNUtils.convertReadableArrayToNumericArray(numbersArray);
                        product.setCustomAttributeWithNumericArray(key, numbersValue);
                        break;
                    default:
                        // Skipped and reported, like parseEvent above.
                        reportSkippedParameter(key, "unknown parameter type " + type);
                        break;
                }
            } catch (Exception exception) {
                Insider.Instance.putException(exception);
            }
        }

        return product;
    }

    public static String mapAppCardsExceptionCode(AppCardsException exception) {
        switch (exception.getCode()) {
            case SDK_NOT_INITIALIZED: return "sdkNotInitialized";
            case INVALID_PARAMETER: return "invalidParameter";
            case NETWORK_ERROR: return "networkError";
            case SERVER_ERROR: return "serverError";
            case PARSE_ERROR: return "parseError";
            default: return "unknown";
        }
    }

    public static WritableMap appCardsErrorToMap(AppCardsException error) {
        WritableMap map = new WritableNativeMap();
        map.putString("code", error != null ? mapAppCardsExceptionCode(error) : "unknown");
        map.putString("message", error != null && error.getMessage() != null ? error.getMessage() : "An unexpected error occurred.");
        return map;
    }

    // JavaScript's own Date range, mirroring the WebView bridge's MAX_DATE_MILLIS
    // (ObjectEntries.kt) and its iOS counterpart in InsiderObjectEntries.swift. A value beyond it
    // cannot come from Date.prototype.getTime() and neither core renders it usefully.
    private static final long MAX_DATE_MILLIS = 8_640_000_000_000_000L;

    private static boolean isSendableEpochMillis(long epochMillis) {
        return epochMillis >= -MAX_DATE_MILLIS && epochMillis <= MAX_DATE_MILLIS;
    }

    /**
     * A skipped parameter otherwise reaches only Insider's own error channel, which the integrating
     * developer never sees - the same reason the refused-match path writes to logcat as well.
     */
    private static void reportSkippedParameter(String key, String reason) {
        Log.w(LOG_TAG, "[ReactNative] skipped parameter " + key + ": " + reason + ".");
        Insider.Instance.putException(new IllegalArgumentException(key + ": " + reason));
    }

    // Mirrors `Double.isWholeNumber()` in the WebView bridge (ObjectEntries.kt): NaN and infinity
    // are not whole, so they are rejected here too.
    private static boolean isWholeNumber(double value) {
        return Double.isFinite(value) && value == Math.rint(value);
    }

    public static Map<String, Object> parseCustomParameters(ReadableArray customParameters) {
        return parseCustomParameters(customParameters, false);
    }

    /**
     * The object path's own entry point. A scalar whole number beyond int32 stays a Long here, which
     * addObjectsToCustomAttribute accepts - the flat path cannot, so it sends a Double instead.
     */
    public static Map<String, Object> parseObjectCustomParameters(ReadableArray customParameters) {
        return parseCustomParameters(customParameters, true);
    }

    private static Map<String, Object> parseCustomParameters(ReadableArray customParameters, boolean allowLongScalars) {
        Map<String, Object> mappedCustomParameters = new HashMap<>();

        if (customParameters == null) {
            return mappedCustomParameters;
        }

        for (int i = 0; i < customParameters.size(); i++) {
            // One try per entry: a single try around the loop dropped every later key too, which on a
            // match silently narrowed it - the one thing the match rule forbids.
            try {
                ReadableMap parameter = customParameters.getMap(i);
                if (parameter == null) continue;

                String type = parameter.hasKey("type") ? parameter.getString("type") : null;
                String key = parameter.hasKey("key") ? parameter.getString("key") : null;
                if (type == null || key == null) continue;

                switch (type) {
                    case "string":
                        if (parameter.hasKey("value") && !parameter.isNull("value")) {
                            mappedCustomParameters.put(key, parameter.getString("value"));
                        }
                        break;
                    case "integer":
                        // Unlike parseEvent/parseProduct (which feed native int-only APIs), this map is a
                        // general-purpose custom-parameters payload (Array-of-Object add/match/value included),
                        // so a value beyond int32 range must be kept intact as a Long rather than truncated.
                        if (parameter.hasKey("value") && !parameter.isNull("value")) {
                            double number = parameter.getDouble("value");
                            // Same wholeness guard as parseEvent/parseProduct: a NaN,
                            // infinite or fractional value tagged `integer` is dropped rather than
                            // truncated. Values beyond int32 range are a separate concern, handled below.
                            if (!isWholeNumber(number)) break;
                            // InsiderEvent.addParameters dispatches on String/Double/Integer/Boolean/
                            // Date/String[]/Number[] - a bare Long matches none of them and is dropped
                            // in silence, so the flat path widens past int32 as a Double instead.
                            Number narrowed = toPreciseNumberOrDouble(number);
                            mappedCustomParameters.put(key,
                                    !allowLongScalars && narrowed instanceof Long ? (Object) number : (Object) narrowed);
                        }
                        break;
                    case "double":
                        if (parameter.hasKey("value") && !parameter.isNull("value")) {
                            mappedCustomParameters.put(key, parameter.getDouble("value"));
                        }
                        break;
                    case "boolean":
                        if (parameter.hasKey("value") && !parameter.isNull("value")) {
                            mappedCustomParameters.put(key, parameter.getBoolean("value"));
                        }
                        break;
                    case "date":
                        if (parameter.hasKey("value") && !parameter.isNull("value")) {
                            double epochMillis = parameter.getDouble("value");
                            // The cast below turns NaN into 0 (1 January 1970) and an infinite value
                            // into Long.MAX_VALUE (year 292278994), both values the partner never wrote,
                            // so check finiteness and range first and drop the entry.
                            if (!Double.isFinite(epochMillis) || !isSendableEpochMillis((long) epochMillis)) break;
                            Date dateValue = new Date((long) epochMillis);
                            mappedCustomParameters.put(key, dateValue);
                        }
                        break;
                    case "numeric_array":
                        if (parameter.hasKey("value") && !parameter.isNull("value")) {
                            ReadableArray arrayValue = parameter.getArray("value");
                            Number[] numberArray = convertReadableArrayToPreciseNumericArray(arrayValue);
                            if (numberArray != null) {
                                mappedCustomParameters.put(key, numberArray);
                            }
                        }
                        break;
                    case "string_array":
                        if (parameter.hasKey("value") && !parameter.isNull("value")) {
                            ReadableArray arrayValue = parameter.getArray("value");
                            String[] stringArray = convertReadableArrayToStringArray(arrayValue);
                            if (stringArray != null) {
                                mappedCustomParameters.put(key, stringArray);
                            }
                        }
                        break;
                    case "object":
                        if (parameter.hasKey("value") && !parameter.isNull("value")) {
                            mappedCustomParameters.put(key, parseCustomParameters(parameter.getArray("value"), allowLongScalars));
                        }
                        break;
                }
            } catch (Exception e) {
                Insider.Instance.putException(e);
            }
        }

        return mappedCustomParameters;
    }

    public static List<Map<String, Object>> parseCustomParametersList(@Nullable ReadableArray list) {
        List<Map<String, Object>> objects = new ArrayList<>();
        try {
            if (list == null) {
                return objects;
            }

            for (int i = 0; i < list.size(); i++) {
                if (list.getType(i) != ReadableType.Array) continue;

                objects.add(parseObjectCustomParameters(list.getArray(i)));
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        return objects;
    }
}