/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

package com.facebook.react.devsupport;

import android.content.Context;
import android.os.AsyncTask;
import androidx.annotation.Nullable;
import com.facebook.common.logging.FLog;
import com.facebook.infer.annotation.Assertions;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.common.ReactConstants;
import com.facebook.react.common.build.ReactBuildConfig;
import com.facebook.react.devsupport.interfaces.DevBundleDownloadListener;
import com.facebook.react.devsupport.interfaces.PackagerStatusCallback;
import com.facebook.react.devsupport.interfaces.StackFrame;
import com.facebook.react.modules.systeminfo.AndroidInfoHelpers;
import com.facebook.react.packagerconnection.FileIoHandler;
import com.facebook.react.packagerconnection.JSPackagerClient;
import com.facebook.react.packagerconnection.NotificationOnlyHandler;
import com.facebook.react.packagerconnection.ReconnectingWebSocket.ConnectionCallback;
import com.facebook.react.packagerconnection.RequestHandler;
import com.facebook.react.packagerconnection.RequestOnlyHandler;
import com.facebook.react.packagerconnection.Responder;
import com.facebook.react.util.RNLog;
import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okio.Okio;
import okio.Sink;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

/**
 * Helper class for all things about the debug server running in the engineer's host machine.
 *
 * <p>One can use 'debug_http_host' shared preferences key to provide a host name for the debug
 * server. If the setting is empty we support and detect two basic configuration that works well for
 * android emulators connection to debug server running on emulator's host:
 *
 * <ul>
 *   <li>Android stock emulator with standard non-configurable local loopback alias: 10.0.2.2
 *   <li>Genymotion emulator with default settings: 10.0.3.2
 * </ul>
 */
public class DevServerHelper {
  public static final String RELOAD_APP_EXTRA_JS_PROXY = "jsproxy";

  private static final int HTTP_CONNECT_TIMEOUT_MS = 5000;

  private static final String DEBUGGER_MSG_DISABLE = "{ \"id\":1,\"method\":\"Debugger.disable\" }";

  public interface OnServerContentChangeListener {
    void onServerContentChanged();
  }

  public interface PackagerCommandListener {
    void onPackagerConnected();

    void onPackagerDisconnected();

    void onPackagerReloadCommand();

    void onPackagerDevMenuCommand();

    void onCaptureHeapCommand(final Responder responder);

    // Allow apps to provide listeners for custom packager commands.
    @Nullable
    Map<String, RequestHandler> customCommandHandlers();
  }

  public interface PackagerCustomCommandProvider {}

  public interface SymbolicationListener {
    void onSymbolicationComplete(@Nullable Iterable<StackFrame> stackFrames);
  }

  private enum BundleType {
    BUNDLE("bundle"),
    MAP("map");

    private final String mTypeID;

    BundleType(String typeID) {
      mTypeID = typeID;
    }

    public String typeID() {
      return mTypeID;
    }
  }

  private final DevInternalSettings mSettings;
  private final OkHttpClient mClient;
  private final BundleDownloader mBundleDownloader;
  private final PackagerStatusCheck mPackagerStatusCheck;
  private final String mPackageName;

  private @Nullable JSPackagerClient mPackagerClient;
  private @Nullable InspectorPackagerConnection mInspectorPackagerConnection;
  private InspectorPackagerConnection.BundleStatusProvider mBundlerStatusProvider;

  public DevServerHelper(
      DevInternalSettings settings,
      String packageName,
      InspectorPackagerConnection.BundleStatusProvider bundleStatusProvider) {
    mSettings = settings;
    mBundlerStatusProvider = bundleStatusProvider;
    mClient =
        new OkHttpClient.Builder()
            .connectTimeout(HTTP_CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
            .readTimeout(0, TimeUnit.MILLISECONDS)
            .writeTimeout(0, TimeUnit.MILLISECONDS)
            .build();
    mBundleDownloader = new BundleDownloader(mClient);
    mPackagerStatusCheck = new PackagerStatusCheck(mClient);
    mPackageName = packageName;
  }

  public void openPackagerConnection(
      final String clientId, final PackagerCommandListener commandListener) {
    if (mPackagerClient != null) {
      FLog.w(ReactConstants.TAG, "Packager connection already open, nooping.");
      return;
    }
    new AsyncTask<Void, Void, Void>() {
      @Override
      protected Void doInBackground(Void... backgroundParams) {
        Map<String, RequestHandler> handlers = new HashMap<>();
        handlers.put(
            "reload",
            new NotificationOnlyHandler() {
              @Override
              public void onNotification(@Nullable Object params) {
                commandListener.onPackagerReloadCommand();
              }
            });
        handlers.put(
            "devMenu",
            new NotificationOnlyHandler() {
              @Override
              public void onNotification(@Nullable Object params) {
                commandListener.onPackagerDevMenuCommand();
              }
            });
        handlers.put(
            "captureHeap",
            new RequestOnlyHandler() {
              @Override
              public void onRequest(@Nullable Object params, Responder responder) {
                commandListener.onCaptureHeapCommand(responder);
              }
            });
        Map<String, RequestHandler> customHandlers = commandListener.customCommandHandlers();
        if (customHandlers != null) {
          handlers.putAll(customHandlers);
        }
        handlers.putAll(new FileIoHandler().handlers());

        ConnectionCallback onPackagerConnectedCallback =
            new ConnectionCallback() {
              @Override
              public void onConnected() {
                commandListener.onPackagerConnected();
              }

              @Override
              public void onDisconnected() {
                commandListener.onPackagerDisconnected();
              }
            };

        mPackagerClient =
            new JSPackagerClient(
                clientId,
                mSettings.getPackagerConnectionSettings(),
                handlers,
                onPackagerConnectedCallback);
        mPackagerClient.init();

        return null;
      }
    }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
  }

  public void closePackagerConnection() {
    new AsyncTask<Void, Void, Void>() {
      @Override
      protected Void doInBackground(Void... params) {
        if (mPackagerClient != null) {
          mPackagerClient.close();
          mPackagerClient = null;
        }
        return null;
      }
    }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
  }

  public void openInspectorConnection() {
    if (mInspectorPackagerConnection != null) {
      FLog.w(ReactConstants.TAG, "Inspector connection already open, nooping.");
      return;
    }
    new AsyncTask<Void, Void, Void>() {
      @Override
      protected Void doInBackground(Void... params) {
        mInspectorPackagerConnection =
            new InspectorPackagerConnection(
                getInspectorDeviceUrl(), mPackageName, mBundlerStatusProvider);
        mInspectorPackagerConnection.connect();
        return null;
      }
    }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
  }

  public void disableDebugger() {
    if (mInspectorPackagerConnection != null) {
      mInspectorPackagerConnection.sendEventToAllConnections(DEBUGGER_MSG_DISABLE);
    }
  }

  public void closeInspectorConnection() {
    new AsyncTask<Void, Void, Void>() {
      @Override
      protected Void doInBackground(Void... params) {
        if (mInspectorPackagerConnection != null) {
          mInspectorPackagerConnection.closeQuietly();
          mInspectorPackagerConnection = null;
        }
        return null;
      }
    }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
  }

  public void openUrl(final ReactContext context, final String url, final String errorMessage) {
    new AsyncTask<Void, String, Boolean>() {
      @Override
      protected Boolean doInBackground(Void... ignore) {
        return doSync();
      }

      public boolean doSync() {
        try {
          String openUrlEndpoint = getOpenUrlEndpoint(context);
          String jsonString = new JSONObject().put("url", url).toString();
          RequestBody body = RequestBody.create(MediaType.parse("application/json"), jsonString);

          Request request = new Request.Builder().url(openUrlEndpoint).post(body).build();
          OkHttpClient client = new OkHttpClient();
          client.newCall(request).execute();
          return true;
        } catch (JSONException | IOException e) {
          FLog.e(ReactConstants.TAG, "Failed to open URL" + url, e);
          return false;
        }
      }

      @Override
      protected void onPostExecute(Boolean result) {
        if (!result) {
          RNLog.w(context, errorMessage);
        }
      }
    }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
  }

  public void symbolicateStackTrace(
      Iterable<StackFrame> stackFrames, final SymbolicationListener listener) {
    try {
      final String symbolicateURL =
          createSymbolicateURL(mSettings.getPackagerConnectionSettings().getDebugServerHost());
      final JSONArray jsonStackFrames = new JSONArray();
      for (final StackFrame stackFrame : stackFrames) {
        jsonStackFrames.put(stackFrame.toJSON());
      }
      final Request request =
          new Request.Builder()
              .url(symbolicateURL)
              .post(
                  RequestBody.create(
                      MediaType.parse("application/json"),
                      new JSONObject().put("stack", jsonStackFrames).toString()))
              .build();
      Call symbolicateCall = Assertions.assertNotNull(mClient.newCall(request));
      symbolicateCall.enqueue(
          new Callback() {
            @Override
            public void onFailure(Call call, IOException e) {
              FLog.w(
                  ReactConstants.TAG,
                  "Got IOException when attempting symbolicate stack trace: " + e.getMessage());
              listener.onSymbolicationComplete(null);
            }

            @Override
            public void onResponse(Call call, final Response response) throws IOException {
              try {
                listener.onSymbolicationComplete(
                    Arrays.asList(
                        StackTraceHelper.convertJsStackTrace(
                            new JSONObject(response.body().string()).getJSONArray("stack"))));
              } catch (JSONException exception) {
                listener.onSymbolicationComplete(null);
              }
            }
          });
    } catch (JSONException e) {
      FLog.w(
          ReactConstants.TAG,
          "Got JSONException when attempting symbolicate stack trace: " + e.getMessage());
    }
  }

  public void openStackFrameCall(StackFrame stackFrame) {
    final String openStackFrameURL =
        createOpenStackFrameURL(mSettings.getPackagerConnectionSettings().getDebugServerHost());
    final Request request =
        new Request.Builder()
            .url(openStackFrameURL)
            .post(
                RequestBody.create(
                    MediaType.parse("application/json"), stackFrame.toJSON().toString()))
            .build();
    Call symbolicateCall = Assertions.assertNotNull(mClient.newCall(request));
    symbolicateCall.enqueue(
        new Callback() {
          @Override
          public void onFailure(Call call, IOException e) {
            FLog.w(
                ReactConstants.TAG,
                "Got IOException when attempting to open stack frame: " + e.getMessage());
          }

          @Override
          public void onResponse(Call call, final Response response) throws IOException {
            // We don't have a listener for this.
          }
        });
  }

  public String getWebsocketProxyURL() {
    return String.format(
        Locale.US,
        "ws://%s/debugger-proxy?role=client",
        mSettings.getPackagerConnectionSettings().getDebugServerHost());
  }

  private String getInspectorDeviceUrl() {
    return String.format(
        Locale.US,
        "http://%s/inspector/device?name=%s&app=%s",
        mSettings.getPackagerConnectionSettings().getInspectorServerHost(),
        AndroidInfoHelpers.getFriendlyDeviceName(),
        mPackageName);
  }

  public void downloadBundleFromURL(
      DevBundleDownloadListener callback,
      File outputFile,
      String bundleURL,
      BundleDownloader.BundleInfo bundleInfo) {
    mBundleDownloader.downloadBundleFromURL(callback, outputFile, bundleURL, bundleInfo);
  }

  private String getOpenUrlEndpoint(Context context) {
    return String.format(
        Locale.US, "http://%s/open-url", AndroidInfoHelpers.getServerHost(context));
  }

  public void downloadBundleFromURL(
      DevBundleDownloadListener callback,
      File outputFile,
      String bundleURL,
      BundleDownloader.BundleInfo bundleInfo,
      Request.Builder requestBuilder) {
    mBundleDownloader.downloadBundleFromURL(
        callback, outputFile, bundleURL, bundleInfo, requestBuilder);
  }

  /** @return the host to use when connecting to the bundle server from the host itself. */
  private String getHostForJSProxy() {
    // Use custom port if configured. Note that host stays "localhost".
    String host =
        Assertions.assertNotNull(mSettings.getPackagerConnectionSettings().getDebugServerHost());
    int portOffset = host.lastIndexOf(':');
    if (portOffset > -1) {
      return "localhost" + host.substring(portOffset);
    } else {
      return AndroidInfoHelpers.DEVICE_LOCALHOST;
    }
  }

  /** @return whether we should enable dev mode when requesting JS bundles. */
  private boolean getDevMode() {
    return mSettings.isJSDevModeEnabled();
  }

  /** @return whether we should request minified JS bundles. */
  private boolean getJSMinifyMode() {
    return mSettings.isJSMinifyEnabled();
  }

  private String createBundleURL(String mainModuleID, BundleType type, String host) {
    return createBundleURL(mainModuleID, type, host, false, true);
  }

  private String createSplitBundleURL(String mainModuleID, String host) {
    return createBundleURL(mainModuleID, BundleType.BUNDLE, host, true, false);
  }

  private String createBundleURL(
      String mainModuleID, BundleType type, String host, boolean modulesOnly, boolean runModule) {
    String runtimeBytecodeVersion =
        ReactBuildConfig.HERMES_BYTECODE_VERSION != 0
            ? "&runtimeBytecodeVersion=" + ReactBuildConfig.HERMES_BYTECODE_VERSION
            : "";
    return String.format(
        Locale.US,
        "http://%s/%s.%s?platform=android&dev=%s&minify=%s&app=%s&modulesOnly=%s&runModule=%s%s",
        host,
        mainModuleID,
        type.typeID(),
        getDevMode(),
        getJSMinifyMode(),
        mPackageName,
        modulesOnly ? "true" : "false",
        runModule ? "true" : "false",
        runtimeBytecodeVersion);
  }

  private String createBundleURL(String mainModuleID, BundleType type) {
    return createBundleURL(
        mainModuleID, type, mSettings.getPackagerConnectionSettings().getDebugServerHost());
  }

  private static String createResourceURL(String host, String resourcePath) {
    return String.format(Locale.US, "http://%s/%s", host, resourcePath);
  }

  private static String createSymbolicateURL(String host) {
    return String.format(Locale.US, "http://%s/symbolicate", host);
  }

  private static String createOpenStackFrameURL(String host) {
    return String.format(Locale.US, "http://%s/open-stack-frame", host);
  }

  public String getDevServerBundleURL(final String jsModulePath) {
    return createBundleURL(
        jsModulePath,
        BundleType.BUNDLE,
        mSettings.getPackagerConnectionSettings().getDebugServerHost());
  }

  public String getDevServerSplitBundleURL(String jsModulePath) {
    return createSplitBundleURL(
        jsModulePath, mSettings.getPackagerConnectionSettings().getDebugServerHost());
  }

  public void isPackagerRunning(final PackagerStatusCallback callback) {
    String host = mSettings.getPackagerConnectionSettings().getDebugServerHost();
    if (host == null) {
      FLog.w(ReactConstants.TAG, "No packager host configured.");
      callback.onPackagerStatusFetched(false);
    } else {
      mPackagerStatusCheck.run(host, callback);
    }
  }

  private String createLaunchJSDevtoolsCommandUrl() {
    return String.format(
        Locale.US,
        "http://%s/launch-js-devtools",
        mSettings.getPackagerConnectionSettings().getDebugServerHost());
  }

  public void launchJSDevtools() {
    Request request = new Request.Builder().url(createLaunchJSDevtoolsCommandUrl()).build();
    mClient
        .newCall(request)
        .enqueue(
            new Callback() {
              @Override
              public void onFailure(Call call, IOException e) {
                // ignore HTTP call response, this is just to open a debugger page and there is no
                // reason
                // to report failures from here
              }

              @Override
              public void onResponse(Call call, Response response) throws IOException {
                // ignore HTTP call response - see above
              }
            });
  }

  public String getSourceMapUrl(String mainModuleName) {
    return createBundleURL(mainModuleName, BundleType.MAP);
  }

  public String getSourceUrl(String mainModuleName) {
    return createBundleURL(mainModuleName, BundleType.BUNDLE);
  }

  public String getJSBundleURLForRemoteDebugging(String mainModuleName) {
    // The host we use when connecting to the JS bundle server from the emulator is not the
    // same as the one needed to connect to the same server from the JavaScript proxy running on the
    // host itself.
    return createBundleURL(mainModuleName, BundleType.BUNDLE, getHostForJSProxy());
  }

  /**
   * This is a debug-only utility to allow fetching a file via packager. It's made synchronous for
   * simplicity, but should only be used if it's absolutely necessary.
   *
   * @return the file with the fetched content, or null if there's any failure.
   */
  public @Nullable File downloadBundleResourceFromUrlSync(
      final String resourcePath, final File outputFile) {
    final String resourceURL =
        createResourceURL(
            mSettings.getPackagerConnectionSettings().getDebugServerHost(), resourcePath);
    final Request request = new Request.Builder().url(resourceURL).build();

    try (Response response = mClient.newCall(request).execute()) {
      if (!response.isSuccessful()) {
        return null;
      }
      Sink output = null;

      try {
        output = Okio.sink(outputFile);
        Okio.buffer(response.body().source()).readAll(output);
      } finally {
        if (output != null) {
          output.close();
        }
      }

      return outputFile;
    } catch (Exception ex) {
      FLog.e(
          ReactConstants.TAG,
          "Failed to fetch resource synchronously - resourcePath: \"%s\", outputFile: \"%s\"",
          resourcePath,
          outputFile.getAbsolutePath(),
          ex);
      return null;
    }
  }
}
