package com.appzung.codepush.react;

import android.app.Activity;
import android.content.SharedPreferences;
import android.os.AsyncTask;
import android.os.Handler;
import android.os.Looper;
import android.view.Choreographer;
import android.view.View;

import androidx.annotation.OptIn;

import com.facebook.react.ReactApplication;
import com.facebook.react.ReactHost;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.ReactRootView;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.BaseJavaModule;
import com.facebook.react.bridge.JSBundleLoader;
import com.facebook.react.bridge.LifecycleEventListener;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.common.annotations.UnstableReactNativeAPI;
import com.facebook.react.devsupport.interfaces.DevSupportManager;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.facebook.react.modules.core.ReactChoreographer;
import com.facebook.react.modules.debug.interfaces.DeveloperSettings;
import com.facebook.react.runtime.ReactHostDelegate;
import com.facebook.react.runtime.ReactHostImpl;

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

import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

@OptIn(markerClass = UnstableReactNativeAPI.class)
public class CodePushNativeModule extends BaseJavaModule {
    private String mBinaryContentsHash = null;
    private String mClientUniqueId = null;
    private boolean mTelemetryEnabled = true;
    private boolean mDataTransmissionEnabled = true;
    private boolean mIsExpoApp = false;
    private LifecycleEventListener mLifecycleEventListener = null;
    private int mMinimumBackgroundDuration = 0;

    private CodePush mCodePush;
    private SettingsManager mSettingsManager;
    private CodePushTelemetryManager mTelemetryManager;
    private CodePushUpdateManager mUpdateManager;

    private boolean _allowed = true;
    private boolean _restartInProgress = false;
    private ArrayList<Boolean> _restartQueue = new ArrayList<>();

    public CodePushNativeModule(ReactApplicationContext reactContext, CodePush codePush, CodePushUpdateManager codePushUpdateManager, CodePushTelemetryManager codePushTelemetryManager, SettingsManager settingsManager) {
        super(reactContext);

        mCodePush = codePush;
        mSettingsManager = settingsManager;
        mTelemetryManager = codePushTelemetryManager;
        mUpdateManager = codePushUpdateManager;

        mIsExpoApp = ExpoUtils.detectExpoEnvironment(reactContext);

        // Initialize module state while we have a reference to the current context.
        mBinaryContentsHash = CodePushUpdateUtils.getHashForBinaryContents(reactContext, mCodePush.isDebugMode());

        SharedPreferences preferences = codePush.getContext().getSharedPreferences(CodePushConstants.CODE_PUSH_PREFERENCES, 0);
        mClientUniqueId = preferences.getString(CodePushConstants.CLIENT_UNIQUE_ID_KEY, null);
        if (mClientUniqueId == null) {
            mClientUniqueId = UUID.randomUUID().toString();
            preferences.edit().putString(CodePushConstants.CLIENT_UNIQUE_ID_KEY, mClientUniqueId).apply();
        }

        if (preferences.contains(CodePushConstants.TELEMETRY_ENABLED_KEY)) {
            mTelemetryEnabled = preferences.getBoolean(CodePushConstants.TELEMETRY_ENABLED_KEY, true);
        } else {
            int defaultTelemetryEnabledResId = reactContext.getResources().getIdentifier("CodePushDefaultTelemetryEnabled", "bool", reactContext.getPackageName());
            if (defaultTelemetryEnabledResId != 0) {
                mTelemetryEnabled = reactContext.getResources().getBoolean(defaultTelemetryEnabledResId);
            } else {
                mTelemetryEnabled = true;
            }
        }

        if (preferences.contains(CodePushConstants.DATA_TRANSMISSION_ENABLED_KEY)) {
            mDataTransmissionEnabled = preferences.getBoolean(CodePushConstants.DATA_TRANSMISSION_ENABLED_KEY, true);
        } else {
            int defaultDataTransmissionEnabledResId = reactContext.getResources().getIdentifier("CodePushDefaultDataTransmissionEnabled", "bool", reactContext.getPackageName());
            if (defaultDataTransmissionEnabledResId != 0) {
                mDataTransmissionEnabled = reactContext.getResources().getBoolean(defaultDataTransmissionEnabledResId);
            } else {
                mDataTransmissionEnabled = true;
            }
        }
    }

    @Override
    public Map<String, Object> getConstants() {
        final Map<String, Object> constants = new HashMap<>();

        constants.put("codePushInstallModeImmediate", CodePushInstallMode.IMMEDIATE.getValue());
        constants.put("codePushInstallModeOnNextRestart", CodePushInstallMode.ON_NEXT_RESTART.getValue());
        constants.put("codePushInstallModeOnNextResume", CodePushInstallMode.ON_NEXT_RESUME.getValue());
        constants.put("codePushInstallModeOnNextSuspend", CodePushInstallMode.ON_NEXT_SUSPEND.getValue());

        constants.put("codePushUpdateStateRunning", CodePushUpdateState.RUNNING.getValue());
        constants.put("codePushUpdateStatePending", CodePushUpdateState.PENDING.getValue());
        constants.put("codePushUpdateStateLatest", CodePushUpdateState.LATEST.getValue());

        return constants;
    }

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

    private void loadBundleLegacy() {
        final Activity currentActivity = getReactApplicationContext().getCurrentActivity();
        if (currentActivity == null) {
            // The currentActivity can be null if it is backgrounded / destroyed, so we simply
            // no-op to prevent any null pointer exceptions.
            return;
        }
        mCodePush.invalidateCurrentInstance();

        currentActivity.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                currentActivity.recreate();
            }
        });
    }

    // Use reflection to find and set the appropriate fields on ReactInstanceManager. See #556 for a proposal for a less brittle way
    // to approach this.
    private void setJSBundle(ReactInstanceManager instanceManager, String latestJSBundleFile) throws IllegalAccessException {
        try {
            JSBundleLoader latestJSBundleLoader;
            if (latestJSBundleFile.toLowerCase().startsWith("assets://")) {
                latestJSBundleLoader = JSBundleLoader.createAssetLoader(getReactApplicationContext(), latestJSBundleFile, false);
            } else {
                latestJSBundleLoader = JSBundleLoader.createFileLoader(latestJSBundleFile);
            }

            Field bundleLoaderField = instanceManager.getClass().getDeclaredField("mBundleLoader");
            bundleLoaderField.setAccessible(true);
            bundleLoaderField.set(instanceManager, latestJSBundleLoader);
        } catch (Exception e) {
            CodePushUtils.log("Unable to set JSBundle of ReactInstanceManager - CodePush may not support this version of React Native");
            throw new IllegalAccessException("Could not setJSBundle");
        }
    }

    // Use reflection to find and set the appropriate fields on ReactHostDelegate. See #556 for a proposal for a less brittle way
    // to approach this.
    private void setJSBundle(ReactHostDelegate reactHostDelegate, String latestJSBundleFile) throws IllegalAccessException {
        try {
            JSBundleLoader latestJSBundleLoader;
            if (latestJSBundleFile.toLowerCase().startsWith("assets://")) {
                latestJSBundleLoader = JSBundleLoader.createAssetLoader(getReactApplicationContext(), latestJSBundleFile, false);
            } else {
                latestJSBundleLoader = JSBundleLoader.createFileLoader(latestJSBundleFile);
            }

            Field bundleLoaderField = reactHostDelegate.getClass().getDeclaredField("jsBundleLoader");
            bundleLoaderField.setAccessible(true);
            bundleLoaderField.set(reactHostDelegate, latestJSBundleLoader);
        } catch (Exception e) {
            CodePushUtils.log("Unable to set JSBundle of ReactHostDelegate - CodePush may not support this version of React Native");
            throw new IllegalAccessException("Could not setJSBundle");
        }
    }

    private void loadBundle() {
        clearLifecycleEventListener();

        if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
            try {
                DevSupportManager devSupportManager = null;
                ReactHost reactHost = resolveReactHost();
                if (reactHost != null) {
                    devSupportManager = reactHost.getDevSupportManager();
                }
                boolean isLiveReloadEnabled = isLiveReloadEnabled(devSupportManager);

                mCodePush.clearDebugCacheIfNeeded(isLiveReloadEnabled);
            } catch (Exception e) {
                // If we got error in out reflection we should clear debug cache anyway.
                mCodePush.clearDebugCacheIfNeeded(false);
            }

            try {
                // #1) Get the ReactHost instance, which is what includes the
                //     logic to reload the current React context.
                final ReactHost reactHost = resolveReactHost();
                if (reactHost == null) {
                    return;
                }

                String latestJSBundleFile = mCodePush.getJSBundleFileInternal(mCodePush.getAssetsBundleFileName());

                // if expo and new arch this is unnecessary, see https://github.com/expo/expo/blob/8113ce44edaef0311e2daff3ab1a63b9731d2d6c/packages/expo-updates/android/src/main/java/expo/modules/updates/procedures/RelaunchProcedure.kt#L148
                if (!mIsExpoApp) {
                    // #2) Update the locally stored JS bundle file path
                    setJSBundle(getReactHostDelegate((ReactHostImpl) reactHost), latestJSBundleFile);
                }

                // #3) Get the context creation method
                try {
                    reactHost.reload("CodePush triggers reload");
                    mCodePush.initializeUpdateAfterRestart();
                } catch (Exception e) {
                    // The recreation method threw an unknown exception
                    // so just simply fallback to restarting the Activity (if it exists)
                    loadBundleLegacy();
                }

            } catch (Exception e) {
                // Our reflection logic failed somewhere
                // so fall back to restarting the Activity (if it exists)
                CodePushUtils.log("Failed to load the bundle, falling back to restarting the Activity (if it exists). " + e.getMessage());
                loadBundleLegacy();
            }
        } else {
            try {
                DevSupportManager devSupportManager = null;
                ReactInstanceManager reactInstanceManager = resolveInstanceManager();
                if (reactInstanceManager != null) {
                    devSupportManager = reactInstanceManager.getDevSupportManager();
                }
                boolean isLiveReloadEnabled = isLiveReloadEnabled(devSupportManager);

                mCodePush.clearDebugCacheIfNeeded(isLiveReloadEnabled);
            } catch (Exception e) {
                // If we got error in out reflection we should clear debug cache anyway.
                mCodePush.clearDebugCacheIfNeeded(false);
            }

            try {
                // #1) Get the ReactInstanceManager instance, which is what includes the
                //     logic to reload the current React context.
                final ReactInstanceManager instanceManager = resolveInstanceManager();
                if (instanceManager == null) {
                    return;
                }

                String latestJSBundleFile = mCodePush.getJSBundleFileInternal(mCodePush.getAssetsBundleFileName());

                // #2) Update the locally stored JS bundle file path
                setJSBundle(instanceManager, latestJSBundleFile);

                // #3) Get the context creation method and fire it on the UI thread (which RN enforces)
                new Handler(Looper.getMainLooper()).post(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            // We don't need to resetReactRootViews anymore
                            // due the issue https://github.com/facebook/react-native/issues/14533
                            // has been fixed in RN 0.46.0
                            //resetReactRootViews(instanceManager);

                            instanceManager.recreateReactContextInBackground();
                            mCodePush.initializeUpdateAfterRestart();
                        } catch (Exception e) {
                            // The recreation method threw an unknown exception
                            // so just simply fallback to restarting the Activity (if it exists)
                            loadBundleLegacy();
                        }
                    }
                });

            } catch (Exception e) {
                // Our reflection logic failed somewhere
                // so fall back to restarting the Activity (if it exists)
                CodePushUtils.log("Failed to load the bundle, falling back to restarting the Activity (if it exists). " + e.getMessage());
                loadBundleLegacy();
            }
        }
    }

    private boolean isLiveReloadEnabled(DevSupportManager devSupportManager) {
        if (devSupportManager == null) {
            return false;
        }

        DeveloperSettings devSettings = devSupportManager.getDevSettings();
        Method[] methods = devSettings.getClass().getMethods();
        for (Method m : methods) {
            if (m.getName().equals("isReloadOnJSChangeEnabled")) {
                try {
                    return (boolean) m.invoke(devSettings);
                } catch (Exception x) {
                    return false;
                }
            }
        }

        return false;
    }

    // This workaround has been implemented in order to fix https://github.com/facebook/react-native/issues/14533
    // resetReactRootViews allows to call recreateReactContextInBackground without any exceptions
    // This fix also relates to https://github.com/microsoft/react-native-code-push/issues/878
    private void resetReactRootViews(ReactInstanceManager instanceManager) throws NoSuchFieldException, IllegalAccessException {
        Field mAttachedRootViewsField = instanceManager.getClass().getDeclaredField("mAttachedRootViews");
        mAttachedRootViewsField.setAccessible(true);
        List<ReactRootView> mAttachedRootViews = (List<ReactRootView>) mAttachedRootViewsField.get(instanceManager);
        for (ReactRootView reactRootView : mAttachedRootViews) {
            reactRootView.removeAllViews();
            reactRootView.setId(View.NO_ID);
        }
        mAttachedRootViewsField.set(instanceManager, mAttachedRootViews);
    }

    private void clearLifecycleEventListener() {
        // Remove LifecycleEventListener to prevent infinite restart loop
        if (mLifecycleEventListener != null) {
            getReactApplicationContext().removeLifecycleEventListener(mLifecycleEventListener);
            mLifecycleEventListener = null;
        }
    }

    // Use reflection to find the ReactInstanceManager. See #556 for a proposal for a less brittle way to approach this.
    private ReactInstanceManager resolveInstanceManager() throws NoSuchFieldException, IllegalAccessException {
        ReactInstanceManager instanceManager = CodePush.getReactInstanceManager();
        if (instanceManager != null) {
            return instanceManager;
        }

        final Activity currentActivity = getReactApplicationContext().getCurrentActivity();
        if (currentActivity == null) {
            return null;
        }

        ReactApplication reactApplication = (ReactApplication) currentActivity.getApplication();
        instanceManager = reactApplication.getReactNativeHost().getReactInstanceManager();

        return instanceManager;
    }

    private ReactHost resolveReactHost() throws NoSuchFieldException, IllegalAccessException {
        ReactHost reactHost = CodePush.getReactHost();
        if (reactHost != null) {
            return reactHost;
        }

        final Activity currentActivity = getReactApplicationContext().getCurrentActivity();
        if (currentActivity == null) {
            return null;
        }

        ReactApplication reactApplication = (ReactApplication) currentActivity.getApplication();
        return reactApplication.getReactHost();
    }

    private void restartAppInternal(boolean onlyIfUpdateIsPending) {
        if (this._restartInProgress) {
            CodePushUtils.log("Restart request queued until the current restart is completed");
            this._restartQueue.add(onlyIfUpdateIsPending);
            return;
        } else if (!this._allowed) {
            CodePushUtils.log("Restart request queued until restarts are re-allowed");
            this._restartQueue.add(onlyIfUpdateIsPending);
            return;
        }

        this._restartInProgress = true;
        if (!onlyIfUpdateIsPending || mSettingsManager.isPendingUpdate(null)) {
            loadBundle();
            CodePushUtils.log("Restarting app");
            return;
        }

        this._restartInProgress = false;
        if (this._restartQueue.size() > 0) {
            boolean buf = this._restartQueue.get(0);
            this._restartQueue.remove(0);
            this.restartAppInternal(buf);
        }
    }

    @ReactMethod
    public void allow(Promise promise) {
        CodePushUtils.log("Re-allowing restarts");
        this._allowed = true;

        if (_restartQueue.size() > 0) {
            CodePushUtils.log("Executing pending restart");
            boolean buf = this._restartQueue.get(0);
            this._restartQueue.remove(0);
            this.restartAppInternal(buf);
        }

        promise.resolve(null);
        return;
    }

    @ReactMethod
    public void clearPendingRestart(Promise promise) {
        this._restartQueue.clear();
        promise.resolve(null);
        return;
    }

    @ReactMethod
    public void disallow(Promise promise) {
        CodePushUtils.log("Disallowing restarts");
        this._allowed = false;
        promise.resolve(null);
        return;
    }

    @ReactMethod
    public void restartApp(boolean onlyIfUpdateIsPending, Promise promise) {
        try {
            restartAppInternal(onlyIfUpdateIsPending);
            promise.resolve(null);
        } catch (CodePushUnknownException e) {
            CodePushUtils.log(e);
            promise.reject(e);
        }
    }

    @ReactMethod
    public void downloadUpdate(final ReadableMap updatePackage, final boolean notifyProgress, final Promise promise) {
        AsyncTask<Void, Void, Void> asyncTask = new AsyncTask<Void, Void, Void>() {
            @Override
            protected Void doInBackground(Void... params) {
                try {
                    JSONObject mutableUpdatePackage = CodePushUtils.convertReadableToJsonObject(updatePackage);
                    CodePushUtils.setJSONValueForKey(mutableUpdatePackage, CodePushConstants.BINARY_MODIFIED_TIME_KEY, "" + mCodePush.getBinaryResourcesModifiedTime());
                    mUpdateManager.downloadPackage(mutableUpdatePackage, mCodePush.getAssetsBundleFileName(), new DownloadProgressCallback() {
                        private boolean hasScheduledNextFrame = false;
                        private DownloadProgress latestDownloadProgress = null;

                        @Override
                        public void call(DownloadProgress downloadProgress) {
                            if (!notifyProgress) {
                                return;
                            }

                            latestDownloadProgress = downloadProgress;
                            // If the download is completed, synchronously send the last event.
                            if (latestDownloadProgress.isCompleted()) {
                                dispatchDownloadProgressEvent();
                                return;
                            }

                            if (hasScheduledNextFrame) {
                                return;
                            }

                            hasScheduledNextFrame = true;
                            getReactApplicationContext().runOnUiQueueThread(new Runnable() {
                                @Override
                                public void run() {
                                    ReactChoreographer.getInstance().postFrameCallback(ReactChoreographer.CallbackType.TIMERS_EVENTS, new Choreographer.FrameCallback() {
                                        @Override
                                        public void doFrame(long frameTimeNanos) {
                                            if (!latestDownloadProgress.isCompleted()) {
                                                dispatchDownloadProgressEvent();
                                            }

                                            hasScheduledNextFrame = false;
                                        }
                                    });
                                }
                            });
                        }

                        public void dispatchDownloadProgressEvent() {
                            getReactApplicationContext()
                                    .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
                                    .emit(CodePushConstants.DOWNLOAD_PROGRESS_EVENT_NAME, latestDownloadProgress.createWritableMap());
                        }
                    }, mCodePush.getPublicKey());

                    JSONObject newPackage = mUpdateManager.getPackage(CodePushUtils.tryGetString(updatePackage, CodePushConstants.PACKAGE_HASH_KEY));
                    promise.resolve(CodePushUtils.convertJsonObjectToWritable(newPackage));
                } catch (CodePushInvalidUpdateException e) {
                    CodePushUtils.log(e);
                    JSONObject failedPackageJson = CodePushUtils.convertReadableToJsonObject(updatePackage);
                    JSONObject failureInfo = buildDownloadFailureInfo(e);
                    mSettingsManager.saveFailedUpdate(failedPackageJson, failureInfo);

                    // Fire-and-forget HTTP report. Same payload as the
                    // next-sync fallback above; if either succeeds the server
                    // gets the failure detail.
                    CodePushDeployReporter.reportFailure(
                            mCodePush.getContext(),
                            mCodePush.getServerUrl(),
                            mCodePush.getReleaseChannelPublicId(),
                            mCodePush.getAppVersion(),
                            failedPackageJson,
                            failureInfo.optString(CodePushConstants.FAILURE_ERROR_CODE_KEY, null),
                            failureInfo.optString(CodePushConstants.FAILURE_ERROR_MESSAGE_KEY, null),
                            failureInfo.optString(CodePushConstants.FAILURE_FAILED_PHASE_KEY, null));

                    promise.reject(e);
                } catch (IOException | CodePushUnknownException e) {
                    CodePushUtils.log(e);
                    promise.reject(e);
                }

                return null;
            }
        };

        asyncTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
    }

    @ReactMethod
    public void getConfiguration(Promise promise) {
        try {
            WritableMap configMap = Arguments.createMap();
            configMap.putString("appVersion", mCodePush.getAppVersion());
            configMap.putString("clientUniqueId", mClientUniqueId);
            configMap.putString("releaseChannelPublicId", mCodePush.getReleaseChannelPublicId());
            configMap.putString("serverUrl", mCodePush.getServerUrl());
            configMap.putBoolean("telemetryEnabled", mTelemetryEnabled);
            configMap.putBoolean("dataTransmissionEnabled", mDataTransmissionEnabled);

            // The binary hash may be null in debug builds
            if (mBinaryContentsHash != null) {
                configMap.putString(CodePushConstants.PACKAGE_HASH_KEY, mBinaryContentsHash);
            }

            promise.resolve(configMap);
        } catch (CodePushUnknownException e) {
            CodePushUtils.log(e);
            promise.reject(e);
        }
    }

    @ReactMethod
    public void getUpdateMetadata(final int updateState, final Promise promise) {
        AsyncTask<Void, Void, Void> asyncTask = new AsyncTask<Void, Void, Void>() {
            @Override
            protected Void doInBackground(Void... params) {
                try {
                    JSONObject currentPackage = mUpdateManager.getCurrentPackage();

                    if (currentPackage == null) {
                        promise.resolve(null);
                        return null;
                    }

                    Boolean currentUpdateIsPending = false;

                    if (currentPackage.has(CodePushConstants.PACKAGE_HASH_KEY)) {
                        String currentHash = currentPackage.optString(CodePushConstants.PACKAGE_HASH_KEY, null);
                        currentUpdateIsPending = mSettingsManager.isPendingUpdate(currentHash);
                    }

                    if (updateState == CodePushUpdateState.PENDING.getValue() && !currentUpdateIsPending) {
                        // The caller wanted a pending update
                        // but there isn't currently one.
                        promise.resolve(null);
                    } else if (updateState == CodePushUpdateState.RUNNING.getValue() && currentUpdateIsPending) {
                        // The caller wants the running update, but the current
                        // one is pending, so we need to grab the previous.
                        JSONObject previousPackage = mUpdateManager.getPreviousPackage();

                        if (previousPackage == null) {
                            promise.resolve(null);
                            return null;
                        }

                        promise.resolve(CodePushUtils.convertJsonObjectToWritable(previousPackage));
                    } else {
                        // The current package satisfies the request:
                        // 1) Caller wanted a pending, and there is a pending update
                        // 2) Caller wanted the running update, and there isn't a pending
                        // 3) Caller wants the latest update, regardless if it's pending or not
                        if (mCodePush.isRunningBinaryVersion()) {
                            // This only matters in Debug builds. Since we do not clear "outdated" updates,
                            // we need to indicate to the JS side that somehow we have a current update on
                            // disk that is not actually running.
                            CodePushUtils.setJSONValueForKey(currentPackage, "_isDebugOnly", true);
                        }

                        // Enable differentiating pending vs. non-pending updates
                        CodePushUtils.setJSONValueForKey(currentPackage, "isPending", currentUpdateIsPending);
                        promise.resolve(CodePushUtils.convertJsonObjectToWritable(currentPackage));
                    }
                } catch (CodePushMalformedDataException e) {
                    // We need to recover the app in case 'codepush.json' is corrupted
                    CodePushUtils.log(e.getMessage());
                    clearUpdates();
                    promise.resolve(null);
                } catch (CodePushUnknownException e) {
                    CodePushUtils.log(e);
                    promise.reject(e);
                }

                return null;
            }
        };

        asyncTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
    }

    @ReactMethod
    public void getNewStatusReport(final Promise promise) {
        AsyncTask<Void, Void, Void> asyncTask = new AsyncTask<Void, Void, Void>() {
            @Override
            protected Void doInBackground(Void... params) {
                try {
                    if (mCodePush.needToReportRollback()) {
                        mCodePush.setNeedToReportRollback(false);
                        JSONArray failedUpdates = mSettingsManager.getFailedUpdates();
                        if (failedUpdates != null && failedUpdates.length() > 0) {
                            try {
                                JSONObject lastFailedPackageJSON = failedUpdates.getJSONObject(failedUpdates.length() - 1);
                                WritableMap lastFailedPackage = CodePushUtils.convertJsonObjectToWritable(lastFailedPackageJSON);
                                WritableMap failedStatusReport = mTelemetryManager.getRollbackReport(lastFailedPackage);
                                if (failedStatusReport != null) {
                                    promise.resolve(failedStatusReport);
                                    return null;
                                }
                            } catch (JSONException e) {
                                throw new CodePushUnknownException("Unable to read failed updates information stored in SharedPreferences.", e);
                            }
                        }
                    } else if (mCodePush.didUpdate()) {
                        JSONObject currentPackage = mUpdateManager.getCurrentPackage();
                        if (currentPackage != null) {
                            WritableMap newPackageStatusReport = mTelemetryManager.getUpdateReport(CodePushUtils.convertJsonObjectToWritable(currentPackage));
                            if (newPackageStatusReport != null) {
                                promise.resolve(newPackageStatusReport);
                                return null;
                            }
                        }
                    } else if (mCodePush.isRunningBinaryVersion()) {
                        WritableMap newAppVersionStatusReport = mTelemetryManager.getBinaryUpdateReport(mCodePush.getAppVersion());
                        if (newAppVersionStatusReport != null) {
                            promise.resolve(newAppVersionStatusReport);
                            return null;
                        }
                    } else {
                        WritableMap retryStatusReport = mTelemetryManager.getRetryStatusReport();
                        if (retryStatusReport != null) {
                            promise.resolve(retryStatusReport);
                            return null;
                        }
                    }

                    promise.resolve("");
                } catch (CodePushUnknownException e) {
                    CodePushUtils.log(e);
                    promise.reject(e);
                }
                return null;
            }
        };

        asyncTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
    }

    @ReactMethod
    public void installUpdate(final ReadableMap updatePackage, final int installMode, final int minimumBackgroundDuration, final Promise promise) {
        AsyncTask<Void, Void, Void> asyncTask = new AsyncTask<Void, Void, Void>() {
            @Override
            protected Void doInBackground(Void... params) {
                try {
                    mUpdateManager.installPackage(CodePushUtils.convertReadableToJsonObject(updatePackage), mSettingsManager.isPendingUpdate(null));

                    String pendingHash = CodePushUtils.tryGetString(updatePackage, CodePushConstants.PACKAGE_HASH_KEY);
                    if (pendingHash == null) {
                        throw new CodePushUnknownException("Update package to be installed has no hash.");
                    } else {
                        mSettingsManager.savePendingUpdate(pendingHash, /* isLoading */false);
                    }

                    if (installMode == CodePushInstallMode.ON_NEXT_RESUME.getValue() ||
                            // We also add the resume listener if the installMode is IMMEDIATE, because
                            // if the current activity is backgrounded, we want to reload the bundle when
                            // it comes back into the foreground.
                            installMode == CodePushInstallMode.IMMEDIATE.getValue() ||
                            installMode == CodePushInstallMode.ON_NEXT_SUSPEND.getValue()) {

                        // Store the minimum duration on the native module as an instance
                        // variable instead of relying on a closure below, so that any
                        // subsequent resume-based installs could override it.
                        CodePushNativeModule.this.mMinimumBackgroundDuration = minimumBackgroundDuration;

                        if (mLifecycleEventListener == null) {
                            // Ensure we do not add the listener twice.
                            mLifecycleEventListener = new LifecycleEventListener() {
                                private Date lastPausedDate = null;
                                private Handler appSuspendHandler = new Handler(Looper.getMainLooper());
                                private Runnable loadBundleRunnable = new Runnable() {
                                    @Override
                                    public void run() {
                                        CodePushUtils.log("Loading bundle on suspend");
                                        restartAppInternal(false);
                                    }
                                };

                                @Override
                                public void onHostResume() {
                                    appSuspendHandler.removeCallbacks(loadBundleRunnable);
                                    // As of RN 36, the resume handler fires immediately if the app is in
                                    // the foreground, so explicitly wait for it to be backgrounded first
                                    if (lastPausedDate != null) {
                                        long durationInBackground = (new Date().getTime() - lastPausedDate.getTime()) / 1000;
                                        if (installMode == CodePushInstallMode.IMMEDIATE.getValue()
                                                || durationInBackground >= CodePushNativeModule.this.mMinimumBackgroundDuration) {
                                            CodePushUtils.log("Loading bundle on resume");
                                            restartAppInternal(false);
                                        }
                                    }
                                }

                                @Override
                                public void onHostPause() {
                                    // Save the current time so that when the app is later
                                    // resumed, we can detect how long it was in the background.
                                    lastPausedDate = new Date();

                                    if (installMode == CodePushInstallMode.ON_NEXT_SUSPEND.getValue() && mSettingsManager.isPendingUpdate(null)) {
                                        appSuspendHandler.postDelayed(loadBundleRunnable, minimumBackgroundDuration * 1000);
                                    }
                                }

                                @Override
                                public void onHostDestroy() {
                                }
                            };

                            getReactApplicationContext().addLifecycleEventListener(mLifecycleEventListener);
                        }
                    }

                    promise.resolve("");
                } catch (CodePushUnknownException e) {
                    CodePushUtils.log(e);
                    promise.reject(e);
                }

                return null;
            }
        };

        asyncTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
    }

    @ReactMethod
    public void isFailedUpdate(String packageHash, Promise promise) {
        try {
            promise.resolve(mSettingsManager.isFailedHash(packageHash));
        } catch (CodePushUnknownException e) {
            CodePushUtils.log(e);
            promise.reject(e);
        }
    }

    @ReactMethod
    public void getLatestRollbackInfo(Promise promise) {
        try {
            JSONObject latestRollbackInfo = mSettingsManager.getLatestRollbackInfo();
            if (latestRollbackInfo != null) {
                promise.resolve(CodePushUtils.convertJsonObjectToWritable(latestRollbackInfo));
            } else {
                promise.resolve(null);
            }
        } catch (CodePushUnknownException e) {
            CodePushUtils.log(e);
            promise.reject(e);
        }
    }

    @ReactMethod
    public void setLatestRollbackInfo(String packageHash, Promise promise) {
        try {
            mSettingsManager.setLatestRollbackInfo(packageHash);
            promise.resolve(null);
        } catch (CodePushUnknownException e) {
            CodePushUtils.log(e);
            promise.reject(e);
        }
    }

    @ReactMethod
    public void isFirstRun(String packageHash, Promise promise) {
        try {
            boolean isFirstRun = mCodePush.didUpdate()
                    && packageHash != null
                    && packageHash.length() > 0
                    && packageHash.equals(mUpdateManager.getCurrentPackageHash());
            promise.resolve(isFirstRun);
        } catch (CodePushUnknownException e) {
            CodePushUtils.log(e);
            promise.reject(e);
        }
    }

    @ReactMethod
    public void notifyApplicationReady(Promise promise) {
        try {
            mSettingsManager.removePendingUpdate();
            promise.resolve("");
        } catch (CodePushUnknownException e) {
            CodePushUtils.log(e);
            promise.reject(e);
        }
    }

    /**
     * JS-facing bridge wrapper around {@link CodePush#reportDeploymentFailure(String, String, String)}.
     * Lets business code that lives in JS (e.g. integrity-check failure detected
     * after the bundle is up) report the same way the native pre-bridge use case
     * does. The underlying instance method is fire-and-forget — this Promise only
     * signals that the bridge dispatch reached native, not that the HTTP POST
     * succeeded (failures fall back to the next-sync FailedUpdates retry).
     *
     * @param errorCode    Recommended values from {@code FailureErrorCode}; capped at 64 chars.
     * @param errorMessage Free-form text. Empty string == "no message". Capped at 500 chars.
     * @param failedPhase  One of {@code download / install / runtime / unknown};
     *                     anything else coerced to {@code "unknown"}. Capped at 20 chars.
     */
    @ReactMethod
    public void reportDeploymentFailure(String errorCode, String errorMessage, String failedPhase, Promise promise) {
        try {
            mCodePush.reportDeploymentFailure(errorCode, errorMessage, failedPhase);
            promise.resolve(null);
        } catch (Throwable e) {
            CodePushUtils.log(e);
            promise.reject("CODE_PUSH_REPORT_DEPLOYMENT_FAILURE_ERROR",
                    e.getMessage() != null ? e.getMessage() : "Failed to report deployment failure",
                    e);
        }
    }

    @ReactMethod
    public void recordStatusReported(ReadableMap statusReport) {
        try {
            mTelemetryManager.recordStatusReported(statusReport);
        } catch (CodePushUnknownException e) {
            CodePushUtils.log(e);
        }
    }

    @ReactMethod
    public void saveStatusReportForRetry(ReadableMap statusReport) {
        try {
            mTelemetryManager.saveStatusReportForRetry(statusReport);
        } catch (CodePushUnknownException e) {
            CodePushUtils.log(e);
        }
    }

    /**
     * Coarse classification of a download-time exception into the failure-info
     * dictionary shape consumed by {@link SettingsManager#saveFailedUpdate(JSONObject, JSONObject)}.
     * The download path on Android only distinguishes a single failure class
     * ({@link CodePushInvalidUpdateException}) so we lean on the message text
     * to upgrade `NETWORK_ERROR` to a finer-grained code when possible.
     */
    private static JSONObject buildDownloadFailureInfo(Throwable error) {
        String message = error != null && error.getMessage() != null ? error.getMessage() : "";
        String errorCode = "NETWORK_ERROR";
        String lower = message.toLowerCase();
        if (lower.contains("hash") || lower.contains("integrity")) {
            errorCode = "HASH_MISMATCH";
        } else if (lower.contains("sign")) {
            errorCode = "SIGNATURE_INVALID";
        } else if (lower.contains("unzip") || lower.contains("zip")
                || lower.contains("invalid update") || lower.contains("could not be found")) {
            errorCode = "UNZIP_FAILED";
        }

        JSONObject info = new JSONObject();
        try {
            info.put(CodePushConstants.FAILURE_ERROR_CODE_KEY, errorCode);
            info.put(CodePushConstants.FAILURE_FAILED_PHASE_KEY, "download");
            if (!message.isEmpty()) {
                info.put(CodePushConstants.FAILURE_ERROR_MESSAGE_KEY, message);
            }
        } catch (JSONException ignored) {
            // Best-effort.
        }
        return info;
    }

    @ReactMethod
    // Replaces the current bundle with the one downloaded from removeBundleUrl.
    // It is only to be used during tests. No-ops if the test configuration flag is not set.
    public void downloadAndReplaceCurrentBundle(String remoteBundleUrl) {
        try {
            if (mCodePush.isUsingTestConfiguration()) {
                try {
                    mUpdateManager.downloadAndReplaceCurrentBundle(remoteBundleUrl, mCodePush.getAssetsBundleFileName());
                } catch (IOException e) {
                    throw new CodePushUnknownException("Unable to replace current bundle", e);
                }
            }
        } catch (CodePushUnknownException | CodePushMalformedDataException e) {
            CodePushUtils.log(e);
        }
    }

    /**
     * This method clears CodePush's downloaded updates.
     * It is needed to switch to a different release channel if the current release channel is more recent.
     * Note: we don’t recommend to use this method in scenarios other than that (CodePush will call
     * this method automatically when needed in other cases) as it could lead to unpredictable
     * behavior.
     */
    @ReactMethod
    public void clearUpdates() {
        CodePushUtils.log("Clearing updates.");
        mCodePush.clearUpdates();
    }

    @ReactMethod
    public void resetClientUniqueId(Promise promise) {
        try {
            String newClientUniqueId = UUID.randomUUID().toString();
            SharedPreferences preferences = mCodePush.getContext().getSharedPreferences(CodePushConstants.CODE_PUSH_PREFERENCES, 0);
            preferences.edit().putString(CodePushConstants.CLIENT_UNIQUE_ID_KEY, newClientUniqueId).apply();
            mClientUniqueId = newClientUniqueId;
            promise.resolve(mClientUniqueId);
        } catch (Exception e) {
            CodePushUtils.log(e);
            promise.reject(e);
        }
    }

    @ReactMethod
    public void setTelemetryEnabled(boolean enabled, Promise promise) {
        try {
            SharedPreferences preferences = mCodePush.getContext().getSharedPreferences(CodePushConstants.CODE_PUSH_PREFERENCES, 0);
            preferences.edit().putBoolean(CodePushConstants.TELEMETRY_ENABLED_KEY, enabled).apply();
            mTelemetryEnabled = enabled;
            promise.resolve(null);
        } catch (Exception e) {
            CodePushUtils.log(e);
            promise.reject(e);
        }
    }

    @ReactMethod
    public void getTelemetryEnabled(Promise promise) {
        promise.resolve(mTelemetryEnabled);
    }

    @ReactMethod
    public void setDataTransmissionEnabled(boolean enabled, Promise promise) {
        try {
            SharedPreferences preferences = mCodePush.getContext().getSharedPreferences(CodePushConstants.CODE_PUSH_PREFERENCES, 0);
            preferences.edit().putBoolean(CodePushConstants.DATA_TRANSMISSION_ENABLED_KEY, enabled).apply();
            mDataTransmissionEnabled = enabled;
            promise.resolve(null);
        } catch (Exception e) {
            CodePushUtils.log(e);
            promise.reject(e);
        }
    }

    @ReactMethod
    public void getDataTransmissionEnabled(Promise promise) {
        promise.resolve(mDataTransmissionEnabled);
    }

    @ReactMethod
    public void addListener(String eventName) {
        // Set up any upstream listeners or background tasks as necessary
    }

    @ReactMethod
    public void removeListeners(Integer count) {
        // Remove upstream listeners, stop unnecessary background tasks
    }

    public ReactHostDelegate getReactHostDelegate(ReactHostImpl reactHostImpl) {
        try {
            Class<?> clazz = reactHostImpl.getClass();
            Field field = clazz.getDeclaredField("mReactHostDelegate");
            field.setAccessible(true);

            // Get the value of the field for the provided instance
            return (ReactHostDelegate) field.get(reactHostImpl);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
            return null;
        }
    }
}
