/**
 * Copyright (c) 2015-present, Facebook, Inc.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 */

package com.facebook.react.devsupport;

import javax.annotation.Nullable;

import java.util.HashMap;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;

import android.os.Handler;
import android.os.Looper;

import com.facebook.infer.annotation.Assertions;
import com.facebook.react.bridge.JavaJSExecutor;

/**
 * Executes JS remotely via the react nodejs server as a proxy to a browser on the host machine.
 */
public class WebsocketJavaScriptExecutor implements JavaJSExecutor {

  private static final long CONNECT_TIMEOUT_MS = 5000;
  private static final int CONNECT_RETRY_COUNT = 3;

  public interface JSExecutorConnectCallback {
    void onSuccess();
    void onFailure(Throwable cause);
  }

  public static class WebsocketExecutorTimeoutException extends Exception {
    public WebsocketExecutorTimeoutException(String message) {
      super(message);
    }
  }

  private static class JSExecutorCallbackFuture implements
    JSDebuggerWebSocketClient.JSDebuggerCallback {

    private final Semaphore mSemaphore = new Semaphore(0);
    private @Nullable Throwable mCause;
    private @Nullable String mResponse;

    @Override
    public void onSuccess(@Nullable String response) {
      mResponse = response;
      mSemaphore.release();
    }

    @Override
    public void onFailure(Throwable cause) {
      mCause = cause;
      mSemaphore.release();
    }

    /**
     * Call only once per object instance!
     */
    public @Nullable String get() throws Throwable {
      mSemaphore.acquire();
      if (mCause != null) {
        throw mCause;
      }
      return mResponse;
    }
  }

  final private HashMap<String, String> mInjectedObjects = new HashMap<>();
  private @Nullable JSDebuggerWebSocketClient mWebSocketClient;

  public void connect(final String webSocketServerUrl, final JSExecutorConnectCallback callback) {
    final AtomicInteger retryCount = new AtomicInteger(CONNECT_RETRY_COUNT);
    final JSExecutorConnectCallback retryProxyCallback = new JSExecutorConnectCallback() {
      @Override
      public void onSuccess() {
        callback.onSuccess();
      }

      @Override
      public void onFailure(Throwable cause) {
        if (retryCount.decrementAndGet() <= 0) {
          callback.onFailure(cause);
        } else {
          connectInternal(webSocketServerUrl, this);
        }
      }
    };
    connectInternal(webSocketServerUrl, retryProxyCallback);
  }

  private void connectInternal(
      String webSocketServerUrl,
      final JSExecutorConnectCallback callback) {
    final JSDebuggerWebSocketClient client = new JSDebuggerWebSocketClient();
    final Handler timeoutHandler = new Handler(Looper.getMainLooper());
    client.connect(
        webSocketServerUrl, new JSDebuggerWebSocketClient.JSDebuggerCallback() {
          // It's possible that both callbacks can fire on an error so make sure we only
          // dispatch results once to our callback.
          private boolean didSendResult = false;

          @Override
          public void onSuccess(@Nullable String response) {
            client.prepareJSRuntime(
                new JSDebuggerWebSocketClient.JSDebuggerCallback() {
                  @Override
                  public void onSuccess(@Nullable String response) {
                    timeoutHandler.removeCallbacksAndMessages(null);
                    mWebSocketClient = client;
                    if (!didSendResult) {
                      callback.onSuccess();
                      didSendResult = true;
                    }
                  }

                  @Override
                  public void onFailure(Throwable cause) {
                    timeoutHandler.removeCallbacksAndMessages(null);
                    if (!didSendResult) {
                      callback.onFailure(cause);
                      didSendResult = true;
                    }
                  }
                });
          }

          @Override
          public void onFailure(Throwable cause) {
            timeoutHandler.removeCallbacksAndMessages(null);
            if (!didSendResult) {
              callback.onFailure(cause);
              didSendResult = true;
            }
          }
        });
    timeoutHandler.postDelayed(
        new Runnable() {
          @Override
          public void run() {
            client.closeQuietly();
            callback.onFailure(
                new WebsocketExecutorTimeoutException(
                    "Timeout while connecting to remote debugger"));
          }
        },
        CONNECT_TIMEOUT_MS);
  }

  @Override
  public void close() {
    if (mWebSocketClient != null) {
      mWebSocketClient.closeQuietly();
    }
  }

  @Override
  public void loadApplicationScript(String sourceURL)
      throws ProxyExecutorException {
    JSExecutorCallbackFuture callback = new JSExecutorCallbackFuture();
    Assertions.assertNotNull(mWebSocketClient).loadApplicationScript(
        sourceURL,
        mInjectedObjects,
        callback);
    try {
      callback.get();
    } catch (Throwable cause) {
      throw new ProxyExecutorException(cause);
    }
  }

  @Override
  public @Nullable String executeJSCall(String methodName, String jsonArgsArray)
      throws ProxyExecutorException {
    JSExecutorCallbackFuture callback = new JSExecutorCallbackFuture();
    Assertions.assertNotNull(mWebSocketClient).executeJSCall(
        methodName,
        jsonArgsArray,
        callback);
    try {
      return callback.get();
    } catch (Throwable cause) {
      throw new ProxyExecutorException(cause);
    }
  }

  @Override
  public void setGlobalVariable(String propertyName, String jsonEncodedValue) {
    // Store and use in the next loadApplicationScript() call.
    mInjectedObjects.put(propertyName, jsonEncodedValue);
  }
}
