package com.useinsider.react;

import androidx.annotation.Nullable;

import android.app.Application;

import android.os.Handler;
import android.util.Log;
import android.os.Looper;
import android.content.Intent;
import android.net.Uri;

import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Callback;
import com.facebook.react.bridge.Dynamic;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
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.WritableMap;
import com.facebook.react.bridge.WritableNativeMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.google.firebase.messaging.RemoteMessage;
import com.useinsider.insider.Insider;
import com.useinsider.insider.InsiderCallback;
import com.useinsider.insider.InsiderCallbackType;
import com.useinsider.insider.InsiderIdentifiers;
import com.useinsider.insider.InsiderUser;
import com.useinsider.insider.AppCardsException;
import com.useinsider.insider.InsiderAppCardButton;
import com.useinsider.insider.InsiderAppCard;
import com.useinsider.insider.InsiderProduct;
import com.useinsider.insider.CloseButtonPosition;
import com.useinsider.insider.MessageCenterData;
import com.useinsider.insider.RecommendationEngine;
import com.useinsider.insider.ContentOptimizerDataType;
import com.useinsider.insider.InsiderIDListener;
import com.useinsider.insider.InsiderEventListener;

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

import java.lang.reflect.Method;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;

import com.facebook.react.bridge.Promise;

public class RNInsiderModule extends ReactContextBaseJavaModule {

    private final ReactApplicationContext reactContext;
    private boolean isCoreInited = false;

    private InsiderIDListener insiderIDListener;

    // Written on the NativeModules thread, read again from the posted main-looper Runnable
    // whose identity check decides whether to register. volatile makes that read's visibility
    // explicit rather than relying on the happens-before edge Handler.post happens to give us.
    private volatile InsiderEventListener insiderEventListener;

    public RNInsiderModule(ReactApplicationContext reactContext) {
        super(reactContext);
        this.reactContext = reactContext;
    }

    @Override
    public String getName() {
        return "RNInsider";
    }

    private static final Method emitDeviceEventMethod = resolveEmitDeviceEvent();

    private static Method resolveEmitDeviceEvent() {
        try {
            return ReactContext.class.getMethod("emitDeviceEvent", String.class, Object.class);
        } catch (NoSuchMethodException e) {
            return null;
        }
    }

    private void emitDeviceEventCompat(String eventName, Object payload) {
        ReactApplicationContext context = getReactApplicationContext();

        if (emitDeviceEventMethod != null) {
            try {
                emitDeviceEventMethod.invoke(context, eventName, payload);
                return;
            } catch (Exception e) {
                Insider.Instance.putException(e);
                return;
            }
        }

        DeviceEventManagerModule.RCTDeviceEventEmitter emitter =
                context.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class);

        if (emitter != null) {
            emitter.emit(eventName, payload);
        }
    }

    @ReactMethod
    public void setName(String name) {
        Insider.Instance.getCurrentUser().setName(name);
    }

    @ReactMethod
    public void init(final String partnerName, final String sdkVersion, final String appIdentifier) {
        try {
            if (Insider.Instance.isSDKInitialized()) {
                return;
            }
            final String app = resolveAppIdentifier(appIdentifier);
            new Handler(Looper.getMainLooper()).post(new Runnable() {
                @Override
                public void run() {
                    Insider.Instance.registerInsiderCallback(new InsiderCallback() {
                        @Override
                        public void doAction(JSONObject jsonObject, InsiderCallbackType insiderCallbackType) {
                            try {
                                WritableMap callbackData = RNUtils.convertJSONObjectToMap(jsonObject);
                                emitDeviceEventCompat(insiderCallbackType.name(), callbackData);
                            } catch (Exception e) {
                                Insider.Instance.putException(e);
                            }
                        }
                    });

                    if (app != null) {
                        Insider.Instance.init(((Application) reactContext.getApplicationContext()), partnerName, app);
                    } else {
                        Insider.Instance.init(((Application) reactContext.getApplicationContext()), partnerName);
                    }
                    Insider.Instance.setSDKType("react-native");
                    Insider.Instance.setHybridSDKVersion(sdkVersion);

                    Insider.Instance.resumeSessionHybridConfig(reactContext.getCurrentActivity());
                    if (isCoreInited) {
                        Insider.Instance.resumeSessionHybridRequestConfig();
                    }
                    isCoreInited = true;

                    Insider.Instance.storePartnerName(partnerName);
                }
            });
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    private String resolveAppIdentifier(String app) {
        return app != null && !app.isEmpty() ? app : null;
    }

    @ReactMethod
    public void reinitWithPartnerName(String partnerName) {
        Insider.Instance.reinitWithPartnerName(partnerName);
    }

    @ReactMethod
    public void setCustomEndpoint(String endpoint) {
        Insider.Instance.setCustomEndpoint(endpoint);
    }

    @ReactMethod
    public void tagEvent(String eventName, ReadableArray parameters) {
        try {
            RNUtils.parseEvent(eventName, parameters).build();
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void setGender(int gender) {
        try {
            Insider.Instance.getCurrentUser().setGender(RNUtils.toInsiderGender(gender));
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void setBirthday(String birthday) {
        try {
            // React Native's bridge does not support long type directly, so we use String to pass epoch time.
            long birthdayEpoch = Long.parseLong(birthday);
            Date birthdayDate = new Date(birthdayEpoch);
            Insider.Instance.getCurrentUser().setBirthday(birthdayDate);
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void setSurname(String surname) {
        Insider.Instance.getCurrentUser().setSurname(surname);
    }

    @ReactMethod
    public void setAge(int age) {
        Insider.Instance.getCurrentUser().setAge(age);
    }

    @ReactMethod
    public void setLanguage(String language) {
        Insider.Instance.getCurrentUser().setLanguage(language);
    }

    @ReactMethod
    public void setLocale(String locale) {
        Insider.Instance.getCurrentUser().setLocale(locale);
    }

    @ReactMethod
    public void setFacebookID(String facebookID) {
        Insider.Instance.getCurrentUser().setFacebookID(facebookID);
    }

    @ReactMethod
    public void setTwitterID(String twitterID) {
        Insider.Instance.getCurrentUser().setTwitterID(twitterID);
    }

    @ReactMethod
    public void setEmailOptin(boolean emailOptin) {
        Insider.Instance.getCurrentUser().setEmailOptin(emailOptin);
    }

    @ReactMethod
    public void setEmail(String email) {
        Insider.Instance.getCurrentUser().setEmail(email);
    }

    @ReactMethod
    public void setSMSOptin(boolean smsOptin) {
        Insider.Instance.getCurrentUser().setSMSOptin(smsOptin);
    }

    @ReactMethod
    public void setPhoneNumber(String phoneNumber) {
        Insider.Instance.getCurrentUser().setPhoneNumber(phoneNumber);
    }

    @ReactMethod
    public void setPushOptin(boolean pushOptin) {
        Insider.Instance.getCurrentUser().setPushOptin(pushOptin);
    }

    @ReactMethod
    public void setLocationOptin(boolean locationOptin) {
        Insider.Instance.getCurrentUser().setLocationOptin(locationOptin);
    }

    @ReactMethod
    public void setWhatsappOptin(boolean whatsappOptin) {
        Insider.Instance.getCurrentUser().setWhatsappOptin(whatsappOptin);
    }

    @ReactMethod
    public void setPromotionalOptin(boolean promotionalOptin) {
        try {
            Insider.Instance.getCurrentUser().setPromotionalOptin(promotionalOptin);
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void login(ReadableMap identifiers) {
        InsiderIdentifiers setIdentifiers = setIdentifiers(identifiers);

        Insider.Instance.getCurrentUser().login(setIdentifiers);
    }

    @ReactMethod
    public void loginWithReturningID(ReadableMap identifiers, final Callback RNInsiderIDResult) {
        try {
            // The core returns on an empty identifier set without invoking insiderIDResult, so answer
            // here. A null-valued key counts as absent - no converter has a Null case.
            if (!hasAnyIdentifierValue(identifiers)) {
                RNInsiderIDResult.invoke((String) null);

                return;
            }

            InsiderIdentifiers setIdentifiers = setIdentifiers(identifiers);

            Insider.Instance.getCurrentUser().login(setIdentifiers, new InsiderUser.InsiderIDResult() {
                @Override
                public void insiderIDResult(String insiderID) {
                    try {
                        RNInsiderIDResult.invoke(insiderID);
                    } catch (Exception e) {
                        Insider.Instance.putException(e);
                    }
                }
            });
        } catch (Exception e) {
            Insider.Instance.putException(e);
            RNInsiderIDResult.invoke((String) null);
        }
    }

    private static boolean hasAnyIdentifierValue(ReadableMap identifiers) {
        if (identifiers == null) {
            return false;
        }

        ReadableMapKeySetIterator iterator = identifiers.keySetIterator();
        while (iterator.hasNextKey()) {
            if (identifiers.getType(iterator.nextKey()) != ReadableType.Null) {
                return true;
            }
        }

        return false;
    }

    private InsiderIdentifiers setIdentifiers(ReadableMap identifiers) {
        try {
            HashMap<String, Object> mappedIdentifiers = RNUtils.convertReadableMapToMap(identifiers);
            InsiderIdentifiers insiderIdentifiers = new InsiderIdentifiers();
            for (String key : mappedIdentifiers.keySet()) {
                switch (key) {
                    case RNUtils.ADD_EMAIL:
                        insiderIdentifiers.addEmail(String.valueOf(mappedIdentifiers.get(key)));
                        break;
                    case RNUtils.ADD_PHONE_NUMBER:
                        insiderIdentifiers.addPhoneNumber(String.valueOf(mappedIdentifiers.get(key)));
                        break;
                    case RNUtils.ADD_USER_ID:
                        insiderIdentifiers.addUserID(String.valueOf(mappedIdentifiers.get(key)));
                        break;
                    default:
                        insiderIdentifiers.addCustomIdentifier(key, String.valueOf(mappedIdentifiers.get(key)));
                        break;
                }
            }
            return insiderIdentifiers;
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }

        return new InsiderIdentifiers();
    }

    @ReactMethod
    public void logout() {
        Insider.Instance.getCurrentUser().logout();
    }

    @ReactMethod
    public void logoutResettingInsiderID(ReadableArray identifiersArray, final Callback insiderIDResult) {
        try {
            InsiderIdentifiers[] identifiers = convertReadableArrayToInsiderIdentifiersArray(identifiersArray);

            if (insiderIDResult != null) {
                Insider.Instance.getCurrentUser().logoutResettingInsiderID(identifiers, new InsiderUser.InsiderIDResult() {
                    @Override
                    public void insiderIDResult(String insiderID) {
                        try {
                            insiderIDResult.invoke(insiderID);
                        } catch (Exception e) {
                            Insider.Instance.putException(e);
                        }
                    }
                });
            } else {
                Insider.Instance.getCurrentUser().logoutResettingInsiderID(identifiers);
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
            if (insiderIDResult != null) {
                insiderIDResult.invoke((String) null);
            }
        }
    }

    private InsiderIdentifiers[] convertReadableArrayToInsiderIdentifiersArray(ReadableArray identifiersArray) {
        if (identifiersArray == null || identifiersArray.size() == 0) {
            return null;
        }

        InsiderIdentifiers[] identifiers = new InsiderIdentifiers[identifiersArray.size()];
        for (int i = 0; i < identifiersArray.size(); i++) {
            ReadableMap identifierMap = identifiersArray.getMap(i);
            identifiers[i] = setIdentifiers(identifierMap);
        }
        return identifiers;
    }

    @ReactMethod
    public void setCustomAttributeWithString(String key, String value) {
        Insider.Instance.getCurrentUser().setCustomAttributeWithString(key, value);
    }

    @ReactMethod
    public void setCustomAttributeWithInt(String key, int value) {
        Insider.Instance.getCurrentUser().setCustomAttributeWithInt(key, value);
    }

    @ReactMethod
    public void setCustomAttributeWithDouble(String key, double value) {
        Insider.Instance.getCurrentUser().setCustomAttributeWithDouble(key, value);
    }

    @ReactMethod
    public void setCustomAttributeWithBoolean(String key, boolean value) {
        Insider.Instance.getCurrentUser().setCustomAttributeWithBoolean(key, value);
    }

    @ReactMethod
    public void setCustomAttributeWithDate(String key, String value) {
        try {
            // React Native's bridge does not support long type directly, so we use String to pass epoch time.
            long valueEpoch = Long.parseLong(value);
            Date valueDate = new Date(valueEpoch);
            Insider.Instance.getCurrentUser().setCustomAttributeWithDate(key, valueDate);
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void setCustomAttributeWithArray(String key, ReadableArray value) {
        try {
            Insider.Instance.getCurrentUser().setCustomAttributeWithArray(key, RNUtils.convertReadableArrayToStringArray(value));
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void addObjectsToCustomAttribute(String key, Dynamic objects) {
        try {
            ReadableArray objectsArray = asArrayOrNull(objects);
            if (objectsArray == null) {
                reportRefusedMatch("addObjectsToCustomAttribute", "objects must be an array");
                return;
            }

            Insider.Instance.getCurrentUser().addObjectsToCustomAttribute(key, RNUtils.parseCustomParametersList(objectsArray));
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void mergeObjectsInCustomAttribute(String key, Dynamic match, Dynamic value) {
        try {
            ReadableArray matchArray = asArrayOrNull(match);
            if (matchArray == null) {
                // Merge requires an explicit match array. An empty match is NOT ignored by the core:
                // `match == null || match.isEmpty()` is one branch there, the irreversible wildcard.
                reportRefusedMatch("mergeObjectsInCustomAttribute", "match must be an array");
                return;
            }

            if (matchArray.size() == 0) {
                // An empty match is the core's irreversible wildcard, so it would merge into every
                // object. JS refuses the same shape; this covers a payload that bypassed it.
                reportRefusedMatch("mergeObjectsInCustomAttribute", "an empty match would merge into every object");
                return;
            }

            Map<String, Object> matchMap = RNUtils.parseObjectCustomParameters(matchArray);
            if (!isIntactMatch(matchArray, matchMap)) {
                // A malformed match entry parses to fewer keys than the incoming array has elements;
                // calling native with the reduced dictionary would widen the merge beyond what was
                // requested, so refuse it instead of failing open.
                reportRefusedMatch("mergeObjectsInCustomAttribute", "match contains an invalid entry");
                return;
            }

            ReadableArray valueArray = asArrayOrNull(value);
            if (valueArray == null || valueArray.size() == 0) {
                // A value that carries nothing merges nothing while still carrying a match, so the
                // call reads as applied while changing no object - the shape JS refuses too.
                reportRefusedMatch("mergeObjectsInCustomAttribute", "value must be a non-empty array");
                return;
            }

            Insider.Instance.getCurrentUser().mergeObjectsInCustomAttribute(key, matchMap, RNUtils.parseObjectCustomParameters(valueArray));
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void removeObjectsFromCustomAttribute(String key, @Nullable Dynamic match) {
        try {
            if (match == null || match.isNull()) {
                // A missing match is the native wildcard remove.
                Insider.Instance.getCurrentUser().removeObjectsFromCustomAttribute(key);
                return;
            }

            ReadableArray matchArray = asArrayOrNull(match);
            if (matchArray == null) {
                reportRefusedMatch("removeObjectsFromCustomAttribute", "match must be an array");
                return;
            }

            if (matchArray.size() == 0) {
                // A match that was passed but is empty is not the wildcard; the core collapses both
                // into one branch, so a hand-built payload would otherwise wipe the attribute.
                reportRefusedMatch("removeObjectsFromCustomAttribute", "an empty match would remove every object");
                return;
            }

            Map<String, Object> matchMap = RNUtils.parseObjectCustomParameters(matchArray);
            if (!isIntactMatch(matchArray, matchMap)) {
                // Same fail-closed guard as mergeObjectsInCustomAttribute above.
                reportRefusedMatch("removeObjectsFromCustomAttribute", "match contains an invalid entry");
                return;
            }

            Insider.Instance.getCurrentUser().removeObjectsFromCustomAttribute(key, matchMap);
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    /** Dynamic rather than ReadableArray so a mistyped value is refused here instead of throwing
     *  while the bridge parses arguments, as iOS already does for the same parameter. */
    private static ReadableArray asArrayOrNull(Dynamic value) {
        if (value == null || value.isNull() || value.getType() != ReadableType.Array) {
            return null;
        }

        return value.asArray();
    }

    /** A key-count comparison only sees the top level, so a nested object that lost a child still
     *  counts as intact - and a narrowed match selects MORE objects than asked. This walks it. */
    private static boolean isIntactMatch(ReadableArray source, Map<String, Object> parsed) {
        return isIntactMatch(source, parsed, 1);
    }

    private static boolean isIntactMatch(ReadableArray source, Map<String, Object> parsed, int depth) {
        // The parser recurses without a depth limit, so a match nested deeper than the core stores
        // parses intact at every level while the core drops the deepest one - the same narrowing the
        // rest of this method refuses. JS refuses the shape too; this covers a payload that bypassed it.
        if (depth > RNUtils.MAX_OBJECT_NESTING_DEPTH) {
            return false;
        }

        if (parsed.size() != source.size()) {
            return false;
        }

        for (int i = 0; i < source.size(); i++) {
            ReadableMap entry = source.getMap(i);
            if (entry == null || !"object".equals(entry.getString("type"))) {
                continue;
            }

            ReadableArray nestedSource = entry.getArray("value");
            Object nestedParsed = parsed.get(entry.getString("key"));
            // An empty nested object carries nothing to match on, and native drops a child that kept
            // no key - the loss cascades until the match is the wildcard. Same size on both sides, so
            // the count check below cannot see it.
            if (nestedSource == null || nestedSource.size() == 0 || !(nestedParsed instanceof Map)) {
                return false;
            }

            @SuppressWarnings("unchecked")
            Map<String, Object> nestedMap = (Map<String, Object>) nestedParsed;
            if (!isIntactMatch(nestedSource, nestedMap, depth + 1)) {
                return false;
            }
        }

        return true;
    }

    private static void reportRefusedMatch(String methodName, String reason) {
        // putException only reaches Insider's own error channel, which the integrating developer
        // never sees. The refusal is silent from their side, so also write it to logcat alongside
        // it. The refusal itself is unchanged.
        Log.w(RNUtils.LOG_TAG, "[ReactNative] " + methodName + " ignored: the call was refused because " + reason + ".");
        Insider.Instance.putException(new IllegalArgumentException(methodName + ": " + reason));
    }

    @ReactMethod
    public void unsetCustomAttribute(String key) {
        Insider.Instance.getCurrentUser().unsetCustomAttribute(key);
    }

    @ReactMethod
    public void setGDPRConsent(final boolean gdprConsent) {
        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                Insider.Instance.setGDPRConsent(gdprConsent);
            }
        });
    }

    @ReactMethod
    public void setMobileAppAccess(final boolean mobileAppAccess) {
        Insider.Instance.setMobileAppAccess(mobileAppAccess);
    }

    @ReactMethod
    public void itemPurchased(String saleID, ReadableMap requiredFields, ReadableMap optionalFields, ReadableArray customParameters, ReadableArray eventCustomParameters) {
        try {
            InsiderProduct product = RNUtils.parseProduct(requiredFields, optionalFields, customParameters);
            Map<String, Object> mappedCustomParameters = RNUtils.parseCustomParameters(eventCustomParameters);

            if (!mappedCustomParameters.isEmpty()) {
                Insider.Instance.itemPurchased(saleID, product, mappedCustomParameters);
            } else {
                Insider.Instance.itemPurchased(saleID, product);
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void itemAddedToCart(ReadableMap requiredFields, ReadableMap optionalFields, ReadableArray customParameters, ReadableArray eventCustomParameters) {
        try {
            InsiderProduct product = RNUtils.parseProduct(requiredFields, optionalFields, customParameters);
            Map<String, Object> mappedCustomParameters = RNUtils.parseCustomParameters(eventCustomParameters);

            if (!mappedCustomParameters.isEmpty()) {
                Insider.Instance.itemAddedToCart(product, mappedCustomParameters);
            } else {
                Insider.Instance.itemAddedToCart(product);
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void itemRemovedFromCart(String productID, String saleID, ReadableArray customParameters) {
        try {
            Map<String, Object> mappedCustomParameters = RNUtils.parseCustomParameters(customParameters);

            if (saleID != null && saleID.length() > 0) {
                Insider.Instance.itemRemovedFromCart(productID, saleID, mappedCustomParameters);
            } else if (!mappedCustomParameters.isEmpty()) {
                Insider.Instance.itemRemovedFromCart(productID, mappedCustomParameters);
            } else {
                Insider.Instance.itemRemovedFromCart(productID);
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void cartCleared(ReadableArray customParameters) {
        try {
            Map<String, Object> mappedCustomParameters = RNUtils.parseCustomParameters(customParameters);

            if (!mappedCustomParameters.isEmpty()) {
                Insider.Instance.cartCleared(mappedCustomParameters);
            } else {
                Insider.Instance.cartCleared();
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void itemAddedToWishlist(ReadableMap requiredFields, ReadableMap optionalFields, ReadableArray customParameters, ReadableArray eventCustomParameters) {
        try {
            InsiderProduct product = RNUtils.parseProduct(requiredFields, optionalFields, customParameters);
            Map<String, Object> mappedCustomParameters = RNUtils.parseCustomParameters(eventCustomParameters);

            if (!mappedCustomParameters.isEmpty()) {
                Insider.Instance.itemAddedToWishlist(product, mappedCustomParameters);
            } else {
                Insider.Instance.itemAddedToWishlist(product);
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void itemRemovedFromWishlist(String productID, ReadableArray customParameters) {
        try {
            Map<String, Object> mappedCustomParameters = RNUtils.parseCustomParameters(customParameters);

            if (!mappedCustomParameters.isEmpty()) {
                Insider.Instance.itemRemovedFromWishlist(productID, mappedCustomParameters);
            } else {
                Insider.Instance.itemRemovedFromWishlist(productID);
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void wishlistCleared(ReadableArray customParameters) {
        try {
            Map<String, Object> mappedCustomParameters = RNUtils.parseCustomParameters(customParameters);

            if (!mappedCustomParameters.isEmpty()) {
                Insider.Instance.wishlistCleared(mappedCustomParameters);
            } else {
                Insider.Instance.wishlistCleared();
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void getMessageCenterData(int limit, String startDate, String endDate, final Callback messageCenterCallback) {
        // The SDK can answer and throw in either order; invoking an RN callback twice throws.
        final AtomicBoolean answered = new AtomicBoolean(false);
        try {
            // React Native's bridge does not support long type directly, so we use String to pass epoch time.
            long startDateEpoch = Long.parseLong(startDate);
            long endDateEpoch = Long.parseLong(endDate);

            Insider.Instance.getMessageCenterData(limit, new Date(startDateEpoch), new Date(endDateEpoch), new MessageCenterData() {
                @Override
                public void loadMessageCenterData(JSONArray messageCenterData) {
                    answerOnce(answered, messageCenterCallback, "getMessageCenterData", toMessageCenterPayload(messageCenterData));
                }
            });
        } catch (Exception e) {
            Insider.Instance.putException(e);
            answerOnce(answered, messageCenterCallback, "getMessageCenterData", "[]");
        }
    }

    @ReactMethod
    public void getMessageCenterDataWithIdentifiers(int limit, String startDate, String endDate, ReadableMap identifiers, final Callback messageCenterCallback) {
        final AtomicBoolean answered = new AtomicBoolean(false);
        try {
            long startDateEpoch = Long.parseLong(startDate);
            long endDateEpoch = Long.parseLong(endDate);

            Insider.Instance.getMessageCenterData(
                    limit,
                    new Date(startDateEpoch),
                    new Date(endDateEpoch),
                    setIdentifiers(identifiers),
                    new MessageCenterData() {
                        @Override
                        public void loadMessageCenterData(JSONArray messageCenterData) {
                            answerOnce(answered, messageCenterCallback, "getMessageCenterDataWithIdentifiers", toMessageCenterPayload(messageCenterData));
                        }
                    });
        } catch (Exception e) {
            Insider.Instance.putException(e);
            answerOnce(answered, messageCenterCallback, "getMessageCenterDataWithIdentifiers", "[]");
        }
    }

    // The converter reports bad elements itself; this catch keeps a converter that throws from leaving the callback waiting.
    private static String toMessageCenterPayload(JSONArray messageCenterData) {
        try {
            return RNUtils.convertJSONArrayToArray(messageCenterData).toString();
        } catch (Exception e) {
            Insider.Instance.putException(e);
            return "[]";
        }
    }

    private static void answerOnce(AtomicBoolean answered, Callback callback, String methodName, String payload) {
        if (!answered.compareAndSet(false, true)) {
            Insider.Instance.putException(new IllegalStateException(methodName + " answered more than once; the later answer was dropped"));
            return;
        }
        try {
            callback.invoke(payload);
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void getSmartRecommendation(int recommendationID, String locale, String currency,
                                       final Callback smartRecommendation) {
        try {
            Insider.Instance.getSmartRecommendation(recommendationID, locale, currency,
                    new RecommendationEngine.SmartRecommendation() {
                        @Override
                        public void loadRecommendationData(JSONObject recommendation) {
                            try {
                                smartRecommendation.invoke(RNUtils.convertJSONObjectToMap(recommendation));
                            } catch (Exception e) {
                                Insider.Instance.putException(e);
                            }
                        }
                    });
        } catch (Exception e) {
            Insider.Instance.putException(e);
            smartRecommendation.invoke(Arguments.createMap());
        }
    }

    @ReactMethod
    public void getSmartRecommendationWithProduct(ReadableMap requiredFields, ReadableMap optionalFields, ReadableArray customParameters,
                                                  int recommendationID, String locale, final Callback callback) {
        try {
            InsiderProduct product = RNUtils.parseProduct(requiredFields, optionalFields, customParameters);
            Insider.Instance.getSmartRecommendationWithProduct(product, recommendationID, locale,
                    new RecommendationEngine.SmartRecommendation() {
                        @Override
                        public void loadRecommendationData(JSONObject recommendation) {
                            try {
                                callback.invoke(RNUtils.convertJSONObjectToMap(recommendation));
                            } catch (Exception e) {
                                Insider.Instance.putException(e);
                            }
                        }
                    });
        } catch (Exception e) {
            Insider.Instance.putException(e);
            callback.invoke(Arguments.createMap());
        }
    }

    @ReactMethod
    public void getSmartRecommendationWithProductIDs(ReadableArray productIDs, int recommendationID, String locale, String currency, final Callback callback) {
        try {
            String[] productIDsOptions = RNUtils.convertReadableArrayToStringArray(productIDs);

            Insider.Instance.getSmartRecommendationWithProductIDs(productIDsOptions, recommendationID, locale, currency,
                    new RecommendationEngine.SmartRecommendation() {
                        @Override
                        public void loadRecommendationData(JSONObject recommendation) {
                            try {
                                callback.invoke(RNUtils.convertJSONObjectToMap(recommendation));
                            } catch (Exception e) {
                                Insider.Instance.putException(e);
                            }
                        }
                    });
        } catch (Exception e) {
            Insider.Instance.putException(e);
            callback.invoke(Arguments.createMap());
        }
    }

    @ReactMethod
    public void clickSmartRecommendationProduct(int recommendationID, ReadableMap requiredFields, ReadableMap optionalFields, ReadableArray customParameters) {
        try {
            InsiderProduct product = RNUtils.parseProduct(requiredFields, optionalFields, customParameters);
            Insider.Instance.clickSmartRecommendationProduct(recommendationID, product);
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    // An unknown dataType or a throwing SDK answers the default; the SDK is only asked with a known type.
    @ReactMethod
    public void getContentStringWithName(String variableName, String defaultValue, int dataType, final Callback callback) {
        String contentString = defaultValue;
        try {
            ContentOptimizerDataType type = RNUtils.toContentOptimizerDataType(dataType);
            if (type != null) {
                contentString = Insider.Instance.getContentStringWithName(variableName, defaultValue, type);
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        answerContent(callback, contentString);
    }

    @ReactMethod
    public void getContentBoolWithName(String variableName, boolean defaultValue, int dataType, final Callback callback) {
        boolean contentBoolean = defaultValue;
        try {
            ContentOptimizerDataType type = RNUtils.toContentOptimizerDataType(dataType);
            if (type != null) {
                contentBoolean = Insider.Instance.getContentBoolWithName(variableName, defaultValue, type);
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        answerContent(callback, contentBoolean);
    }

    @ReactMethod
    public void getContentIntWithName(String variableName, int defaultValue, int dataType, final Callback callback) {
        int contentInt = defaultValue;
        try {
            ContentOptimizerDataType type = RNUtils.toContentOptimizerDataType(dataType);
            if (type != null) {
                contentInt = Insider.Instance.getContentIntWithName(variableName, defaultValue, type);
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        answerContent(callback, contentInt);
    }

    @ReactMethod
    public void getContentStringWithoutCache(String variableName, String defaultValue, int dataType, final Callback callback) {
        String contentString = defaultValue;
        try {
            ContentOptimizerDataType type = RNUtils.toContentOptimizerDataType(dataType);
            if (type != null) {
                contentString = Insider.Instance.getContentStringWithoutCache(variableName, defaultValue, type);
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        answerContent(callback, contentString);
    }

    @ReactMethod
    public void getContentBoolWithoutCache(String variableName, boolean defaultValue, int dataType, final Callback callback) {
        boolean contentBoolean = defaultValue;
        try {
            ContentOptimizerDataType type = RNUtils.toContentOptimizerDataType(dataType);
            if (type != null) {
                contentBoolean = Insider.Instance.getContentBoolWithoutCache(variableName, defaultValue, type);
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        answerContent(callback, contentBoolean);
    }

    @ReactMethod
    public void getContentIntWithoutCache(String variableName, int defaultValue, int dataType, final Callback callback) {
        int contentInt = defaultValue;
        try {
            ContentOptimizerDataType type = RNUtils.toContentOptimizerDataType(dataType);
            if (type != null) {
                contentInt = Insider.Instance.getContentIntWithoutCache(variableName, defaultValue, type);
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
        answerContent(callback, contentInt);
    }

    private void answerContent(Callback callback, Object value) {
        try {
            callback.invoke(value);
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void markContentOptimizerAsSeen(String variableName) {
        try {
            Insider.Instance.markContentOptimizerAsSeen(variableName);
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }


    @ReactMethod
    public void visitHomePage(ReadableArray customParameters) {
        try {
            Map<String, Object> mappedCustomParameters = RNUtils.parseCustomParameters(customParameters);

            if (!mappedCustomParameters.isEmpty()) {
                Insider.Instance.visitHomePage(mappedCustomParameters);
            } else {
                Insider.Instance.visitHomePage();
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void visitListingPage(ReadableArray taxonomy, ReadableArray customParameters) {
        try {
            String[] taxonomies = RNUtils.convertReadableArrayToStringArray(taxonomy);
            Map<String, Object> mappedCustomParameters = RNUtils.parseCustomParameters(customParameters);

            if (!mappedCustomParameters.isEmpty()) {
                Insider.Instance.visitListingPage(taxonomies, mappedCustomParameters);
            } else {
                Insider.Instance.visitListingPage(taxonomies);
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void visitProductDetailPage(ReadableMap requiredFields, ReadableMap optionalFields, ReadableArray customParameters, ReadableArray eventCustomParameters) {
        try {
            InsiderProduct product = RNUtils.parseProduct(requiredFields, optionalFields, customParameters);
            Map<String, Object> mappedCustomParameters = RNUtils.parseCustomParameters(eventCustomParameters);

            if (!mappedCustomParameters.isEmpty()) {
                Insider.Instance.visitProductDetailPage(product, mappedCustomParameters);
            } else {
                Insider.Instance.visitProductDetailPage(product);
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void visitCartPage(ReadableArray products, String saleID, ReadableArray customParameters) {
        try {
            InsiderProduct[] ips = new InsiderProduct[products.size()];
            for (int i = 0; i < products.size(); i++) {
                ReadableMap productMap = products.getMap(i);
                ReadableMap requiredFields = productMap.getMap("requiredFields");
                ReadableMap optionalFields = productMap.getMap("optionalFields");
                ReadableArray productCustomParameters = productMap.getArray("customParameters");
                InsiderProduct product = RNUtils.parseProduct(requiredFields, optionalFields, productCustomParameters);
                ips[i] = product;
            }

            Map<String, Object> mappedCustomParameters = RNUtils.parseCustomParameters(customParameters);

            if (saleID != null && saleID.length() > 0) {
                Insider.Instance.visitCartPage(ips, saleID, mappedCustomParameters);
            } else if (!mappedCustomParameters.isEmpty()) {
                Insider.Instance.visitCartPage(ips, mappedCustomParameters);
            } else {
                Insider.Instance.visitCartPage(ips);
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void visitWishlistPage(ReadableArray products, ReadableArray customParameters) {
        try {
            InsiderProduct[] ips = new InsiderProduct[products.size()];
            for (int i = 0; i < products.size(); i++) {
                ReadableMap productMap = products.getMap(i);
                ReadableMap requiredFields = productMap.getMap("requiredFields");
                ReadableMap optionalFields = productMap.getMap("optionalFields");
                ReadableArray productCustomParameters = productMap.getArray("customParameters");
                InsiderProduct product = RNUtils.parseProduct(requiredFields, optionalFields, productCustomParameters);
                ips[i] = product;
            }

            Map<String, Object> mappedCustomParameters = RNUtils.parseCustomParameters(customParameters);

            if (!mappedCustomParameters.isEmpty()) {
                Insider.Instance.visitWishlistPage(ips, mappedCustomParameters);
            } else {
                Insider.Instance.visitWishlistPage(ips);
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void startTrackingGeofence() {
        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                Insider.Instance.startTrackingGeofence();
            }
        });
    }

    @ReactMethod
    public void setAllowsBackgroundLocationUpdates(final boolean allowsBackgroundLocationUpdates) {}

    @ReactMethod
    public void removeInapp() {
        try {
            Insider.Instance.removeInapp(reactContext.getCurrentActivity());
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void handleNotification(ReadableMap remoteMessageData) {
        try {
            if (remoteMessageData == null)
                return;
            Map<String, String> remoteMessageStringMap = new HashMap<>();
            for (String key : RNUtils.convertReadableMapToMap(remoteMessageData).keySet()) {
                remoteMessageStringMap.put(key, String.valueOf(RNUtils.convertReadableMapToMap(remoteMessageData).get(key)));
            }
            String provider = Insider.Instance.getCurrentProvider(reactContext);
            switch (provider) {
                case "huawei":
                    com.huawei.hms.push.RemoteMessage hmsRemoteMessage = new com.huawei.hms.push.RemoteMessage.Builder("insider").setData(remoteMessageStringMap).build();
                    Insider.Instance.handleHMSNotification(reactContext, hmsRemoteMessage);
                    break;
                case "other":
                case "google":
                    RemoteMessage fcmRemoteMessage = new RemoteMessage.Builder("insider").setData(remoteMessageStringMap).build();
                    Insider.Instance.handleFCMNotification(reactContext, fcmRemoteMessage);
                    break;
                default:
                    break;
            }

        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void triggerPushProcessWithNotificationData(ReadableMap remoteMessageData) {
        try {
            if (remoteMessageData == null)
                return;
            Map<String, Object> convertedMap = RNUtils.convertReadableMapToMap(remoteMessageData);
            Map<String, String> remoteMessageStringMap = new HashMap<>();
            for (String key : convertedMap.keySet()) {
                remoteMessageStringMap.put(key, String.valueOf(convertedMap.get(key)));
            }
            Insider.Instance.triggerPushProcessWithNotificationData(reactContext, remoteMessageStringMap);
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    // Deprecated
    @ReactMethod
    public void enableIDFACollection(final boolean enableIDFACollection) {}

    @ReactMethod
    public void setHybridPushToken(String token) {
        try {
            Insider.Instance.setHybridPushToken(token);
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void putErrorLog(String exception) {
        Insider.Instance.putException(new Exception(exception));
    }

    @ReactMethod
    public void showNativeAppReview() {
        try {
            Insider.Instance.showNativeRating();
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void enableLocationCollection(final boolean consentStatus) {
        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                Insider.Instance.enableLocationCollection(consentStatus);
            }
        });
    }

    @ReactMethod
    public void enableIpCollection(final boolean consentStatus) {
        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                Insider.Instance.enableIpCollection(consentStatus);
            }
        });
    }

    @ReactMethod
    public void enableCarrierCollection(final boolean consentStatus) {
        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                Insider.Instance.enableCarrierCollection(consentStatus);
            }
        });
    }

    @ReactMethod
    public void signUpConfirmation(ReadableArray customParameters) {
        try {
            Map<String, Object> mappedCustomParameters = RNUtils.parseCustomParameters(customParameters);

            if (!mappedCustomParameters.isEmpty()) {
                Insider.Instance.signUpConfirmation(mappedCustomParameters);
            } else {
                Insider.Instance.signUpConfirmation();
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void setInternalBrowserCloseButtonPosition(String position) {
        try {
            if (position == null) return;
            CloseButtonPosition enumPosition = CloseButtonPosition.valueOf(position);
            Insider.Instance.setInternalBrowserCloseButtonPosition(enumPosition);
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void handleUniversalLink(String url) {
        try {
            Intent universalLinkIntent = new Intent();
            Uri data = Uri.parse(url);

            universalLinkIntent.setData(data);

            Insider.Instance.handleUniversalLink(universalLinkIntent);
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void setActiveForegroundPushView() {}

    @ReactMethod
    public void setForegroundPushCallback() {}

    @ReactMethod
    public void getInsiderID(final Promise promise) {
        try {
            promise.resolve(Insider.Instance.getInsiderID());
        } catch (Exception e) {
            Insider.Instance.putException(e);

            promise.resolve(null);
        }
    }

    @ReactMethod
    public void registerInsiderIDListener() {
        try {
            if (insiderIDListener == null) {
                new Handler(Looper.getMainLooper()).post(new Runnable() {
                    @Override
                    public void run() {
                        insiderIDListener = new InsiderIDListener() {
                            @Override
                            public void onUpdated(String insiderID) {
                                emitDeviceEventCompat("INSIDER_ID_LISTENER", insiderID);
                            }
                        };


                        Insider.Instance.registerInsiderIDListener(insiderIDListener);
                    }
                });
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    /**
     * Registers a single {@link InsiderEventListener} that forwards every recorded event to
     * JS as {@code INSIDER_EVENT_RECORDED}. The JS layer fans out to its own subscribers, so
     * this bridge only ever needs one native observer — the null-guard makes repeat calls
     * idempotent.
     *
     * <p>Note the SDK delivers on its own background thread; only the REGISTRATION is hopped
     * onto the main looper (matching {@link #registerInsiderIDListener()}). The
     * {@code parameters} map handed to us is unmodifiable, so we only read from it.
     *
     * <p>{@code Insider.Instance.events()} returns a no-op instance before SDK init, so it is
     * resolved fresh here at registration time rather than cached in a field.
     */
    @ReactMethod
    public void registerInsiderEventListener() {
        try {
            // The guard and the field assignment BOTH happen synchronously on the calling
            // thread. Deferring the assignment into the posted Runnable would let the guard
            // race unregisterInsiderEventListener(), which reads the field synchronously:
            // a subscribe-then-immediate-unsubscribe would see null, no-op, and then leave
            // the Runnable to register an observer JS believes is gone; a subscribe burst
            // would see null twice and addObserver() twice, double-delivering every event.
            // Only the SDK call itself is hopped onto the main looper.
            if (insiderEventListener != null) return;

            final InsiderEventListener listener = new InsiderEventListener() {
                @Override
                public void onEventRecorded(String name, Map<String, Object> parameters, long timestamp) {
                    try {
                        WritableMap payload = new WritableNativeMap();
                        payload.putString("name", name);
                        payload.putMap("parameters", RNUtils.convertObjectMapToWritableMap(parameters));
                        // Passed through in WHOLE SECONDS, matching the native SDK.
                        // JS must do `new Date(timestamp * 1000)`.
                        payload.putDouble("timestamp", (double) timestamp);

                        // THREADING (matches the note in iOS RNInsider.m — do not
                        // re-litigate): this runs on the SDK's listener thread and
                        // needs no manual hop. RCTDeviceEventEmitter.emit is a
                        // JS-module proxy call; the bridge enqueues it onto the JS
                        // thread itself, in order, so emitting from here is safe.
                        //
                        // The emitter is resolved per emit rather than cached: a dev
                        // reload or Activity recreation replaces the catalyst instance,
                        // and a cached proxy would then target a dead one and silently
                        // stop delivering. emitDeviceEventCompat also covers bridgeless,
                        // where getJSModule may return null.
                        emitDeviceEventCompat("INSIDER_EVENT_RECORDED", payload);
                    } catch (Exception e) {
                        Insider.Instance.putException(e);
                    }
                }
            };

            insiderEventListener = listener;

            new Handler(Looper.getMainLooper()).post(new Runnable() {
                @Override
                public void run() {
                    try {
                        // An unregister may have landed while this was queued. Only register
                        // if this listener is still the current one, so we never revive an
                        // observer the JS side has already dropped.
                        if (insiderEventListener != listener) return;

                        Insider.Instance.events().addObserver(listener);
                    } catch (Exception e) {
                        if (insiderEventListener == listener) insiderEventListener = null;
                        Insider.Instance.putException(e);
                    }
                }
            });
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    /**
     * Removes the observer registered by {@link #registerInsiderEventListener()}. Android
     * holds listeners STRONGLY, so this teardown is required to avoid leaking the listener
     * for the lifetime of the process.
     *
     * <p>The field is cleared synchronously so a register that is still queued sees the
     * identity change and cancels itself. Both methods post to the same looper, so the SDK
     * add/remove calls also stay in submission order.
     */
    @ReactMethod
    public void unregisterInsiderEventListener() {
        try {
            if (insiderEventListener != null) {
                final InsiderEventListener listenerToRemove = insiderEventListener;
                insiderEventListener = null;

                new Handler(Looper.getMainLooper()).post(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            Insider.Instance.events().removeObserver(listenerToRemove);
                        } catch (Exception e) {
                            Insider.Instance.putException(e);
                        }
                    }
                });
            }
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    /**
     * Tears the event observer down when the bridge itself goes away — a dev reload or an
     * Activity recreation destroys the catalyst instance without JS ever getting to call
     * {@link #unregisterInsiderEventListener()}.
     *
     * <p>This matters because Android holds observers STRONGLY: without this the listener
     * survives the bridge, keeps the dead {@code ReactApplicationContext} alive through it,
     * and every subsequent reload stacks another observer onto the SDK. The delegation keeps
     * the teardown sequence (synchronous field clear, then the main-looper removeObserver) in
     * one place rather than duplicating it here.
     */
    @Override
    public void invalidate() {
        unregisterInsiderEventListener();
        super.invalidate();
    }

    @ReactMethod
    public void disableInAppMessages() {
        Insider.Instance.disableInAppMessages();
    }

    @ReactMethod
    public void enableInAppMessages() {
        Insider.Instance.enableInAppMessages();
    }

    @ReactMethod
    public void getAppCardsCampaigns(final Callback callback) {
        try {
            Insider.Instance.appCards().getCampaigns((responseObject, error) -> {
                if (error != null || responseObject == null) {
                    WritableMap errorDetails = RNUtils.appCardsErrorToMap(error);
                    callback.invoke(errorDetails);
                } else {
                    JSONObject jsonObject = responseObject.toJSONObject();
                    WritableMap writableMap = RNUtils.convertJSONObjectToMap(jsonObject);
                    callback.invoke(null, writableMap);
                }
            });
        } catch (Exception e) {
            callback.invoke(RNUtils.appCardsErrorToMap(null));
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void markAppCardsAsRead(final ReadableArray appCardIds, final Callback callback) {
        try {
            String[] array = RNUtils.convertReadableArrayToStringArray(appCardIds);
            Insider.Instance.appCards().markAsRead(array, error -> {
                if (error != null) {
                    WritableMap errorDetails = RNUtils.appCardsErrorToMap(error);
                    callback.invoke(errorDetails);
                } else {
                    callback.invoke();
                }
            });
        } catch (Exception e) {
            callback.invoke(RNUtils.appCardsErrorToMap(null));
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void markAppCardsAsUnread(final ReadableArray appCardIds, final Callback callback) {
        try {
            String[] array = RNUtils.convertReadableArrayToStringArray(appCardIds);
            Insider.Instance.appCards().markAsUnread(array, error -> {
                if (error != null) {
                    WritableMap errorDetails = RNUtils.appCardsErrorToMap(error);
                    callback.invoke(errorDetails);
                } else {
                    callback.invoke();
                }
            });
        } catch (Exception e) {
            callback.invoke(RNUtils.appCardsErrorToMap(null));
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void deleteAppCards(final ReadableArray appCardIds, final Callback callback) {
        try {
            String[] array = RNUtils.convertReadableArrayToStringArray(appCardIds);
            Insider.Instance.appCards().delete(array, error -> {
                if (error != null) {
                    WritableMap errorDetails = RNUtils.appCardsErrorToMap(error);
                    callback.invoke(errorDetails);
                } else {
                    callback.invoke();
                }
            });
        } catch (Exception e) {
            callback.invoke(RNUtils.appCardsErrorToMap(null));
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void clickAppCardButton(final String appCardId, final ReadableMap data) {
        try {
            HashMap<String, Object> map = RNUtils.convertReadableMapToMap(data);
            JSONObject jsonObject = new JSONObject(map);
            new InsiderAppCardButton(appCardId, jsonObject).click();
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void viewAppCard(final ReadableMap data) {
        try {
            HashMap<String, Object> map = RNUtils.convertReadableMapToMap(data);
            JSONObject jsonObject = new JSONObject(map);

            new InsiderAppCard(jsonObject).view();
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }

    @ReactMethod
    public void clickAppCard(final ReadableMap data) {
        try {
            HashMap<String, Object> map = RNUtils.convertReadableMapToMap(data);
            JSONObject jsonObject = new JSONObject(map);

            new InsiderAppCard(jsonObject).click();
        } catch (Exception e) {
            Insider.Instance.putException(e);
        }
    }
}
