// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package androidx.test.espresso.flutter.internal.protocol.impl;

import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.util.concurrent.Futures.transform;
import static com.google.common.util.concurrent.MoreExecutors.directExecutor;

import android.graphics.Rect;
import android.util.Log;
import androidx.test.espresso.flutter.api.FlutterTestingProtocol;
import androidx.test.espresso.flutter.api.SyntheticAction;
import androidx.test.espresso.flutter.api.WidgetMatcher;
import androidx.test.espresso.flutter.internal.idgenerator.IdGenerator;
import androidx.test.espresso.flutter.internal.jsonrpc.JsonRpcClient;
import androidx.test.espresso.flutter.internal.jsonrpc.message.JsonRpcRequest;
import androidx.test.espresso.flutter.internal.jsonrpc.message.JsonRpcResponse;
import androidx.test.espresso.flutter.internal.protocol.impl.GetOffsetAction.OffsetType;
import androidx.test.espresso.flutter.model.WidgetInfo;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Function;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;

/**
 * An implementation of the Espresso-Flutter testing protocol by using the testing APIs exposed by
 * Dart VM service protocol.
 *
 * @see <a href="https://github.com/dart-lang/sdk/blob/main/runtime/vm/service/service.md">Dart VM
 *     Service Protocol</a>.
 */
public final class DartVmService implements FlutterTestingProtocol {

  private static final String TAG = DartVmService.class.getSimpleName();

  private static final Gson gson =
      new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create();

  /** Prefix to be attached to the JSON-RPC message id. */
  private static final String MESSAGE_ID_PREFIX = "message-";

  /** The JSON-RPC method for testing extension APIs. */
  private static final String TESTING_EXTENSION_METHOD = "ext.flutter.driver";
  /** The JSON-RPC method for retrieving Dart isolate info. */
  private static final String GET_ISOLATE_METHOD = "getIsolate";
  /** The JSON-RPC method for retrieving Dart VM info. */
  private static final String GET_VM_METHOD = "getVM";

  /** Json property name for the Dart VM isolate id. */
  private static final String ISOLATE_ID_TAG = "isolateId";

  private final JsonRpcClient client;
  private final IdGenerator<Integer> messageIdGenerator;
  private final String isolateId;
  private final ListeningExecutorService taskExecutor;

  /**
   * Constructs a {@code DartVmService} instance that can be used to talk to the testing protocol
   * exposed by Dart VM service extension protocol. It uses the given {@code isolateId} in all the
   * JSON-RPC requests. It waits until the service extension protocol is in a usable state before
   * returning.
   *
   * @param isolateId the Dart isolate ID to be used in the JSON-RPC requests sent to Dart VM
   *     service protocol.
   * @param jsonRpcClient a JSON-RPC web socket connection to send requests to the Dart VM service
   *     protocol.
   * @param messageIdGenerator an ID generator for generating the JSON-RPC request IDs.
   * @param taskExecutor an executor for running async tasks.
   */
  public DartVmService(
      String isolateId,
      JsonRpcClient jsonRpcClient,
      IdGenerator<Integer> messageIdGenerator,
      ExecutorService taskExecutor) {
    this.isolateId =
        checkNotNull(
            isolateId, "The ID of the Dart isolate that draws the Flutter UI shouldn't be null.");
    this.client =
        checkNotNull(
            jsonRpcClient,
            "The JsonRpcClient used to talk to Dart VM service protocol shouldn't be null.");
    this.messageIdGenerator =
        checkNotNull(
            messageIdGenerator, "The id generator for generating request IDs shouldn't be null.");
    this.taskExecutor = MoreExecutors.listeningDecorator(checkNotNull(taskExecutor));
  }

  /**
   * {@inheritDoc}
   *
   * <p>This method ensures the Dart VM service is ready for use by checking:
   *
   * <ul>
   *   <li>Dart VM Observatory is up and running.
   *   <li>The Flutter testing API is registered with the running Dart VM service protocol.
   * </ul>
   */
  @Override
  @SuppressWarnings("unchecked")
  public Future<Void> connect() {
    return (Future<Void>) taskExecutor.submit(new IsDartVmServiceReady(isolateId, this));
  }

  @Override
  public Future<Void> perform(
      @Nullable final WidgetMatcher widgetMatcher, final SyntheticAction action) {
    // Assumes all the actions require a response.
    ListenableFuture<JsonRpcResponse> responseFuture =
        client.request(getActionRequest(widgetMatcher, action));
    Function<JsonRpcResponse, Void> resultTransformFunc =
        new Function<JsonRpcResponse, Void>() {
          public Void apply(JsonRpcResponse response) {
            if (response.getError() == null) {
              return null;
            } else {
              // TODO(https://github.com/android/android-test/issues/251): Update error case handling
              // like
              // AmbiguousWidgetMatcherException, NoMatchingWidgetException after nailing down the
              // design with
              // Flutter team.
              throw new RuntimeException(
                  String.format(
                      "Error occurred when performing the given action %s on widget matched %s",
                      action, widgetMatcher));
            }
          }
        };
    return transform(responseFuture, resultTransformFunc, directExecutor());
  }

  @Override
  public Future<WidgetInfo> matchWidget(@Nonnull WidgetMatcher widgetMatcher) {
    JsonRpcRequest request = getActionRequest(widgetMatcher, new GetWidgetDiagnosticsAction());
    ListenableFuture<JsonRpcResponse> jsonResponseFuture = client.request(request);

    Function<JsonRpcResponse, WidgetInfo> widgetInfoTransformer =
        new Function<JsonRpcResponse, WidgetInfo>() {
          public WidgetInfo apply(JsonRpcResponse jsonResponse) {
            GetWidgetDiagnosticsResponse widgetDiagnostics =
                GetWidgetDiagnosticsResponse.fromJsonRpcResponse(jsonResponse);
            return WidgetInfoFactory.createWidgetInfo(widgetDiagnostics);
          }
        };
    return transform(jsonResponseFuture, widgetInfoTransformer, directExecutor());
  }

  @Override
  public Future<Rect> getLocalRect(@Nonnull WidgetMatcher widgetMatcher) {
    ListenableFuture<JsonRpcResponse> topLeftFuture =
        client.request(getActionRequest(widgetMatcher, new GetOffsetAction(OffsetType.TOP_LEFT)));
    ListenableFuture<JsonRpcResponse> bottomRightFuture =
        client.request(
            getActionRequest(widgetMatcher, new GetOffsetAction(OffsetType.BOTTOM_RIGHT)));
    ListenableFuture<List<JsonRpcResponse>> responses =
        Futures.allAsList(topLeftFuture, bottomRightFuture);
    Function<List<JsonRpcResponse>, Rect> rectTransformer =
        new Function<List<JsonRpcResponse>, Rect>() {
          public Rect apply(List<JsonRpcResponse> jsonResponses) {
            GetOffsetResponse topLeft = GetOffsetResponse.fromJsonRpcResponse(jsonResponses.get(0));
            GetOffsetResponse bottomRight =
                GetOffsetResponse.fromJsonRpcResponse(jsonResponses.get(1));
            checkState(
                topLeft.getX() >= 0 && topLeft.getY() >= 0,
                String.format(
                    "The relative coordinates [%.1f, %.1f] of a widget's top left vertex cannot be"
                        + " negative (negative means it's off the outer Flutter view)!",
                    topLeft.getX(), topLeft.getY()));
            checkState(
                bottomRight.getX() >= 0 && bottomRight.getY() >= 0,
                String.format(
                    "The relative coordinates [%.1f, %.1f] of a widget's bottom right vertex cannot"
                        + " be negative (negative means it's off the outer Flutter view)!",
                    bottomRight.getX(), bottomRight.getY()));
            checkState(
                topLeft.getX() <= bottomRight.getX() && topLeft.getY() <= bottomRight.getY(),
                String.format(
                    "The coordinates of the bottom right vertex [%.1f, %.1f] are not actually to the"
                        + " bottom right of the top left vertex [%.1f, %.1f]!",
                    topLeft.getX(), topLeft.getY(), bottomRight.getX(), bottomRight.getY()));
            return new Rect(
                (int) topLeft.getX(),
                (int) topLeft.getY(),
                (int) bottomRight.getX(),
                (int) bottomRight.getY());
          }
        };
    return transform(responses, rectTransformer, directExecutor());
  }

  @Override
  public Future<Void> waitUntilIdle() {
    return perform(
        null,
        new WaitForConditionAction(
            new NoPendingPlatformMessagesCondition(),
            new NoTransientCallbacksCondition(),
            new NoPendingFrameCondition()));
  }

  @Override
  public void close() {
    if (client != null) {
      client.disconnect();
    }
  }

  /** Queries the Dart isolate information. */
  public ListenableFuture<JsonRpcResponse> getIsolateInfo() {
    JsonRpcRequest getIsolateReq =
        new JsonRpcRequest.Builder(GET_ISOLATE_METHOD)
            .setId(getNextMessageId())
            .addParam(ISOLATE_ID_TAG, isolateId)
            .build();
    return client.request(getIsolateReq);
  }

  /** Queries the Dart VM information. */
  public ListenableFuture<GetVmResponse> getVmInfo() {
    JsonRpcRequest getVmReq =
        new JsonRpcRequest.Builder(GET_VM_METHOD).setId(getNextMessageId()).build();
    ListenableFuture<JsonRpcResponse> jsonGetVmResp = client.request(getVmReq);
    Function<JsonRpcResponse, GetVmResponse> jsonToResponse =
        new Function<JsonRpcResponse, GetVmResponse>() {
          public GetVmResponse apply(JsonRpcResponse jsonResp) {
            return GetVmResponse.fromJsonRpcResponse(jsonResp);
          }
        };
    return transform(jsonGetVmResp, jsonToResponse, directExecutor());
  }

  /** Gets the next usable message id. */
  private String getNextMessageId() {
    return MESSAGE_ID_PREFIX + messageIdGenerator.next();
  }

  /** Constructs a {@code JsonRpcRequest} based on the given matcher and action. */
  private JsonRpcRequest getActionRequest(WidgetMatcher widgetMatcher, SyntheticAction action) {
    checkNotNull(action, "Action cannot be null.");
    // Assumes all the actions require a response.
    return new JsonRpcRequest.Builder(TESTING_EXTENSION_METHOD)
        .setId(getNextMessageId())
        .setParams(constructParams(isolateId, widgetMatcher, action))
        .build();
  }

  /** Constructs the JSON-RPC request params. */
  private static JsonObject constructParams(
      String isolateId, WidgetMatcher widgetMatcher, SyntheticAction action) {
    JsonObject paramObject = new JsonObject();
    paramObject.addProperty(ISOLATE_ID_TAG, isolateId);
    if (widgetMatcher != null) {
      paramObject = merge(paramObject, (JsonObject) gson.toJsonTree(widgetMatcher));
    }
    paramObject = merge(paramObject, (JsonObject) gson.toJsonTree(action));
    return paramObject;
  }

  /**
   * Returns a merged {@code JsonObject} of the two given {@code JsonObject}s, or an empty {@code
   * JsonObject} if both of the objects to be merged are null.
   */
  private static JsonObject merge(@Nullable JsonObject obj1, @Nullable JsonObject obj2) {
    JsonObject result = new JsonObject();
    mergeTo(result, obj1);
    mergeTo(result, obj2);
    return result;
  }

  private static void mergeTo(JsonObject obj, @Nullable JsonObject toBeMerged) {
    if (toBeMerged != null) {
      for (Map.Entry<String, JsonElement> entry : toBeMerged.entrySet()) {
        obj.add(entry.getKey(), entry.getValue());
      }
    }
  }

  /** A {@link Runnable} that waits until the Dart VM testing extension is ready for use. */
  static class IsDartVmServiceReady implements Runnable {

    /** Maximum number of retries for checking extension APIs' availability. */
    private static final int EXTENSION_API_CHECKING_RETRIES = 5;

    /** Json param name for retrieving all the available extension APIs. */
    private static final String EXTENSION_RPCS_TAG = "extensionRPCs";

    private final String isolateId;
    private final DartVmService dartVmService;

    IsDartVmServiceReady(String isolateId, DartVmService dartVmService) {
      this.isolateId = checkNotNull(isolateId);
      this.dartVmService = checkNotNull(dartVmService);
    }

    @Override
    public void run() {
      waitForTestingApiRegistered();
    }

    /**
     * Blocks until the Flutter testing/driver API is registered with the running Dart VM service
     * protocol by querying whether it's listed in the isolate's 'extensionRPCs'.
     */
    @VisibleForTesting
    void waitForTestingApiRegistered() {
      int retries = EXTENSION_API_CHECKING_RETRIES;
      boolean isApiRegistered = false;
      do {
        retries--;
        try {
          JsonRpcResponse isolateResp = dartVmService.getIsolateInfo().get();
          isApiRegistered = isTestingApiRegistered(isolateResp);
        } catch (ExecutionException e) {
          Log.d(
              TAG,
              "Error occurred during retrieving Dart isolate information. Retry.",
              e.getCause());
          continue;
        } catch (InterruptedException e) {
          Log.d(
              TAG,
              "InterruptedException occurred during retrieving Dart isolate information. Retry.",
              e);
          Thread.currentThread().interrupt(); // Restores the interrupted status.
          continue;
        }
      } while (!isApiRegistered && retries > 0);

      if (!isApiRegistered) {
        throw new FlutterProtocolException(
            String.format("Flutter testing APIs not registered with Dart isolate %s.", isolateId));
      }
    }

    @VisibleForTesting
    boolean isTestingApiRegistered(JsonRpcResponse isolateInfoResp) {
      if (isolateInfoResp == null
          || isolateInfoResp.getError() != null
          || isolateInfoResp.getResult() == null) {
        Log.w(
            TAG,
            String.format(
                "Error occurred in JSON-RPC response when querying isolate info for %s: %s.",
                isolateId, isolateInfoResp.getError()));
        return false;
      }
      for (JsonElement jsonElement :
          isolateInfoResp.getResult().get(EXTENSION_RPCS_TAG).getAsJsonArray()) {
        String extensionApi = jsonElement.getAsString();
        if (TESTING_EXTENSION_METHOD.equals(extensionApi)) {
          Log.d(
              TAG,
              String.format("Flutter testing API registered with Dart isolate %s.", isolateId));
          return true;
        }
      }
      return false;
    }
  }
}
