/*
 * Copyright 2017 The Chromium 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 io.flutter.inspector;

import com.google.common.base.Joiner;
import com.google.gson.*;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.editor.Document;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.editor.ex.EditorEx;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.module.ModuleManager;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.roots.ModuleRootManager;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.util.SystemInfo;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.PsiDocumentManager;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiFile;
import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.xdebugger.XSourcePosition;
import com.intellij.xdebugger.evaluation.XDebuggerEditorsProvider;
import com.intellij.xdebugger.impl.XSourcePositionImpl;
import com.jetbrains.lang.dart.psi.DartCallExpression;
import com.jetbrains.lang.dart.psi.DartExpression;
import com.jetbrains.lang.dart.psi.DartReferenceExpression;
import io.flutter.bazel.Workspace;
import io.flutter.bazel.WorkspaceCache;
import io.flutter.pub.PubRoot;
import io.flutter.run.FlutterDebugProcess;
import io.flutter.run.daemon.FlutterApp;
import io.flutter.utils.StreamSubscription;
import io.flutter.utils.VmServiceListenerAdapter;
import io.flutter.vmService.ServiceExtensions;
import io.flutter.vmService.VmServiceConsumers;
import io.flutter.vmService.frame.DartVmServiceValue;
import org.dartlang.analysis.server.protocol.FlutterOutline;
import org.dartlang.vm.service.VmService;
import org.dartlang.vm.service.consumer.ServiceExtensionConsumer;
import org.dartlang.vm.service.element.Event;
import org.dartlang.vm.service.element.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.List;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.BiConsumer;
import java.util.function.Supplier;

/**
 * Manages all communication between inspector code running on the DartVM and
 * inspector code running in the IDE.
 */
public class InspectorService implements Disposable {

  public static class Location {

    public Location(@NotNull VirtualFile file, int line, int column, int offset) {
      this.file = file;
      this.line = line;
      this.column = column;
      this.offset = offset;
    }

    @NotNull private final VirtualFile file;
    public final int line;
    public final int column;
    private final int offset;

    public int getLine() {
      return line;
    }

    public int getColumn() {
      return column;
    }

    public int getOffset() {
      return offset;
    }

    @NotNull
    public VirtualFile getFile() {
      return file;
    }

    @NotNull
    public String getPath() {
      return toSourceLocationUri(file.getPath());
    }

    @Nullable
    public XSourcePosition getXSourcePosition() {
      final int line = getLine();
      final int column = getColumn();
      if (line < 0 || column < 0) {
        return null;
      }
      return XSourcePositionImpl.create(file, line - 1, column - 1);
    }

    /**
     * Returns a location for a FlutterOutline object that makes a best effort
     * to be compatible with the locations generated by the flutter kernel
     * transformer to track creation locations.
     */
    public static @Nullable
    InspectorService.Location outlineToLocation(Editor editor, FlutterOutline outline) {
      if (!(editor instanceof EditorEx)) return null;
      final EditorEx editorEx = (EditorEx)editor;
      return outlineToLocation(editor.getProject(), editorEx.getVirtualFile(), outline, editor.getDocument());
    }

    public static InspectorService.Location outlineToLocation(Project project,
                                                              VirtualFile file,
                                                              FlutterOutline outline,
                                                              Document document) {
      if (file == null) return null;
      if (document == null) return null;
      if (outline == null || outline.getClassName() == null) return null;
      final int documentLength = document.getTextLength();
      int nodeOffset = Math.max(0, Math.min(outline.getCodeOffset(), documentLength));
      final int nodeEndOffset = Math.max(0, Math.min(outline.getCodeOffset() + outline.getCodeLength(), documentLength));

      // The DartOutline will give us the offset of
      // 'child: Foo.bar(...)'
      // but we need the offset of 'bar(...)' for consistentency with the
      // Flutter kernel transformer.
      if (outline.getClassName() != null) {
        final PsiFile psiFile = PsiDocumentManager.getInstance(project).getPsiFile(document);
        if (psiFile != null) {
          final PsiElement element = psiFile.findElementAt(nodeOffset);
          final DartCallExpression callExpression = PsiTreeUtil.getParentOfType(element, DartCallExpression.class);
          PsiElement match = null;
          if (callExpression != null) {
            final DartExpression expression = callExpression.getExpression();
            if (expression instanceof DartReferenceExpression) {
              final DartReferenceExpression referenceExpression = (DartReferenceExpression)expression;
              final PsiElement[] children = referenceExpression.getChildren();
              if (children.length > 1) {
                // This case handles expressions like 'ClassName.namedConstructor'
                // and 'libraryPrefix.ClassName.namedConstructor'
                match = children[children.length - 1];
              }
              else {
                // this case handles the simple 'ClassName' case.
                match = referenceExpression;
              }
            }
          }
          if (match != null) {
            nodeOffset = match.getTextOffset();
          }
        }
      }
      final int line = document.getLineNumber(nodeOffset);
      final int lineStartOffset = document.getLineStartOffset(line);
      final int column = nodeOffset - lineStartOffset;
      return new InspectorService.Location(file, line + 1, column + 1, nodeOffset);
    }
  }

  private static int nextGroupId = 0;

  public static class InteractiveScreenshot {
    InteractiveScreenshot(Screenshot screenshot, ArrayList<DiagnosticsNode> boxes, ArrayList<DiagnosticsNode> elements) {
      this.screenshot = screenshot;
      this.boxes = boxes;
      this.elements = elements;
    }

    public final Screenshot screenshot;
    public final ArrayList<DiagnosticsNode> boxes;
    public final ArrayList<DiagnosticsNode> elements;
  }

  @NotNull private final FlutterApp app;
  @NotNull private final FlutterDebugProcess debugProcess;
  @NotNull private final VmService vmService;
  @NotNull private final Set<InspectorServiceClient> clients;
  @NotNull private final EvalOnDartLibrary inspectorLibrary;
  @NotNull private final Set<String> supportedServiceMethods;

  private final StreamSubscription<Boolean> setPubRootDirectoriesSubscription;

  /**
   * Convenience ObjectGroup constructor for users who need to use DiagnosticsNode objects before the InspectorService is available.
   */
  public static CompletableFuture<InspectorService.ObjectGroup> createGroup(
    @NotNull FlutterApp app, @NotNull FlutterDebugProcess debugProcess,
    @NotNull VmService vmService, String groupName) {
    return create(app, debugProcess, vmService).thenApplyAsync((service) -> service.createObjectGroup(groupName));
  }

  public static CompletableFuture<InspectorService> create(@NotNull FlutterApp app,
                                                           @NotNull FlutterDebugProcess debugProcess,
                                                           @NotNull VmService vmService) {
    assert app.getVMServiceManager() != null;
    final Set<String> inspectorLibraryNames = new HashSet<>();
    inspectorLibraryNames.add("package:flutter/src/widgets/widget_inspector.dart");
    final EvalOnDartLibrary inspectorLibrary = new EvalOnDartLibrary(
      inspectorLibraryNames,
      vmService,
      app.getVMServiceManager()
    );
    final CompletableFuture<Library> libraryFuture =
      inspectorLibrary.libraryRef.thenComposeAsync((library) -> inspectorLibrary.getLibrary(library, null));
    return libraryFuture.thenComposeAsync((Library library) -> {
      for (ClassRef classRef : library.getClasses()) {
        if ("WidgetInspectorService".equals(classRef.getName())) {
          return inspectorLibrary.getClass(classRef, null).thenApplyAsync((ClassObj classObj) -> {
            final Set<String> functionNames = new HashSet<>();
            for (FuncRef funcRef : classObj.getFunctions()) {
              functionNames.add(funcRef.getName());
            }
            return functionNames;
          });
        }
      }
      throw new RuntimeException("WidgetInspectorService class not found");
    }).thenApplyAsync(
      (supportedServiceMethods) -> new InspectorService(
        app, debugProcess, vmService, inspectorLibrary, supportedServiceMethods));
  }

  private InspectorService(@NotNull FlutterApp app,
                           @NotNull FlutterDebugProcess debugProcess,
                           @NotNull VmService vmService,
                           @NotNull EvalOnDartLibrary inspectorLibrary,
                           @NotNull Set<String> supportedServiceMethods) {
    this.vmService = vmService;
    this.app = app;
    this.debugProcess = debugProcess;
    this.inspectorLibrary = inspectorLibrary;
    this.supportedServiceMethods = supportedServiceMethods;

    clients = new HashSet<>();

    vmService.addVmServiceListener(new VmServiceListenerAdapter() {
      @Override
      public void received(String streamId, Event event) {
        onVmServiceReceived(streamId, event);
      }

      @Override
      public void connectionClosed() {
        // TODO(jacobr): dispose?
      }
    });

    vmService.streamListen(VmService.EXTENSION_STREAM_ID, VmServiceConsumers.EMPTY_SUCCESS_CONSUMER);

    assert (app.getVMServiceManager() != null);
    setPubRootDirectoriesSubscription =
      app.getVMServiceManager().hasServiceExtension(ServiceExtensions.setPubRootDirectories, (Boolean available) -> {
        if (!available) {
          return;
        }
        final Workspace workspace = WorkspaceCache.getInstance(app.getProject()).get();
        final ArrayList<String> rootDirectories = new ArrayList<>();
        if (workspace != null) {
          for (VirtualFile root : rootsForProject(app.getProject())) {
            final String relativePath = workspace.getRelativePath(root);
            // TODO(jacobr): is it an error that the relative path can sometimes be null?
            if (relativePath != null) {
              rootDirectories.add(Workspace.BAZEL_URI_SCHEME + "/" + relativePath);
            }
          }
        }
        else {
          for (PubRoot root : app.getPubRoots()) {
            String path = root.getRoot().getCanonicalPath();
            if (SystemInfo.isWindows) {
              // TODO(jacobr): remove after https://github.com/flutter/flutter-intellij/issues/2217.
              // The problem is setPubRootDirectories is currently expecting
              // valid URIs as opposed to windows paths.
              path = "file:///" + path;
            }
            rootDirectories.add(path);
          }
        }
        setPubRootDirectories(rootDirectories);
      });
  }

  @NotNull
  private static List<VirtualFile> rootsForProject(@NotNull Project project) {
    final List<VirtualFile> result = new ArrayList<>();
    for (Module module : ModuleManager.getInstance(project).getModules()) {
      Collections.addAll(result, ModuleRootManager.getInstance(module).getContentRoots());
    }
    return result;
  }

  /**
   * Returns whether to use the VM service extension API or use eval to invoke
   * the protocol directly.
   * <p>
   * Eval must be used when paused at a breakpoint as the VM Service extensions
   * API calls won't execute until after the current frame is done rendering.
   * TODO(jacobr): evaluate whether we should really be trying to execute while
   * a frame is rendering at all as the Element tree may be in a broken state.
   */
  private boolean useServiceExtensionApi() {
    return !app.isFlutterIsolateSuspended();
  }

  public boolean isDetailsSummaryViewSupported() {
    return hasServiceMethod("getSelectedSummaryWidget");
  }

  public boolean isHotUiScreenMirrorSupported() {
    // Somewhat arbitrarily chosen new API that is required for full Hot UI
    // support.
    return hasServiceMethod("getBoundingBoxes");
  }

  /**
   * Use this method to write code that is backwards compatible with versions
   * of Flutter that are too old to contain specific service methods.
   */
  private boolean hasServiceMethod(String methodName) {
    return supportedServiceMethods.contains(methodName);
  }

  @NotNull
  public FlutterDebugProcess getDebugProcess() {
    return debugProcess;
  }

  public FlutterApp getApp() {
    return debugProcess.getApp();
  }

  public ObjectGroup createObjectGroup(String debugName) {
    return new ObjectGroup(this, debugName);
  }

  @NotNull
  private EvalOnDartLibrary getInspectorLibrary() {
    return inspectorLibrary;
  }

  @Override
  public void dispose() {
    Disposer.dispose(inspectorLibrary);
    Disposer.dispose(setPubRootDirectoriesSubscription);
  }

  public CompletableFuture<?> forceRefresh() {
    final List<CompletableFuture<?>> futures = new ArrayList<>();

    for (InspectorServiceClient client : clients) {
      final CompletableFuture<?> future = client.onForceRefresh();
      if (future != null && !future.isDone()) {
        futures.add(future);
      }
    }

    if (futures.isEmpty()) {
      return CompletableFuture.completedFuture(null);
    }
    return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
  }

  private void notifySelectionChanged(boolean uiAlreadyUpdated, boolean textEditorUpdated) {
    ApplicationManager.getApplication().invokeLater(() -> {
      for (InspectorServiceClient client : clients) {
        client.onInspectorSelectionChanged(uiAlreadyUpdated, textEditorUpdated);
      }
    });
  }

  public void addClient(InspectorServiceClient client) {
    clients.add(client);
  }

  public void removeClient(InspectorServiceClient client) {
    clients.remove(client);
  }

  private void onVmServiceReceived(String streamId, Event event) {
    switch (streamId) {
      case VmService.DEBUG_STREAM_ID: {
        if (event.getKind() == EventKind.Inspect) {
          // Make sure the WidgetInspector on the device switches to show the inspected object
          // if the inspected object is a Widget or RenderObject.

          // We create a dummy object group as this particular operation
          // doesn't actually require an object group.
          createObjectGroup("dummy").setSelection(event.getInspectee(), true, true);
          // Update the UI in IntelliJ.
          notifySelectionChanged(false, false);
        }
        break;
      }
      case VmService.EXTENSION_STREAM_ID: {
        if ("Flutter.Frame".equals(event.getExtensionKind())) {
          ApplicationManager.getApplication().invokeLater(() -> {
            for (InspectorServiceClient client : clients) {
              client.onFlutterFrame();
            }
          });
        }
        break;
      }
      default:
    }
  }

  /**
   * If the widget tree is not ready, the application should wait for the next
   * Flutter.Frame event before attempting to display the widget tree. If the
   * application is ready, the next Flutter.Frame event may never come as no
   * new frames will be triggered to draw unless something changes in the UI.
   */
  public CompletableFuture<Boolean> isWidgetTreeReady() {
    if (useServiceExtensionApi()) {
      return invokeServiceExtensionNoGroup("isWidgetTreeReady", new JsonObject())
        .thenApplyAsync((JsonElement element) -> element.getAsBoolean());
    }
    else {
      return invokeEvalNoGroup("isWidgetTreeReady")
        .thenApplyAsync((InstanceRef ref) -> "true".equals(ref.getValueAsString()));
    }
  }

  CompletableFuture<JsonElement> invokeServiceExtensionNoGroup(String methodName, List<String> args) {
    final JsonObject params = new JsonObject();
    for (int i = 0; i < args.size(); ++i) {
      params.addProperty("arg" + i, args.get(i));
    }
    return invokeServiceExtensionNoGroup(methodName, params);
  }

  private CompletableFuture<Void> setPubRootDirectories(List<String> rootDirectories) {
    if (useServiceExtensionApi()) {
      return invokeServiceExtensionNoGroup("setPubRootDirectories", rootDirectories).thenApplyAsync((ignored) -> null);
    }
    else {
      // TODO(jacobr): remove this call as soon as
      // `ext.flutter.inspector.*` has been in two revs of the Flutter Beta
      // channel. The feature landed in the Flutter dev chanel on
      // April 16, 2018.
      final JsonArray jsonArray = new JsonArray();
      for (String rootDirectory : rootDirectories) {
        jsonArray.add(rootDirectory);
      }
      return getInspectorLibrary().eval(
        "WidgetInspectorService.instance.setPubRootDirectories(" + new Gson().toJson(jsonArray) + ")", null, null)
        .thenApplyAsync((instance) -> null);
    }
  }

  CompletableFuture<InstanceRef> invokeEvalNoGroup(String methodName) {
    return getInspectorLibrary().eval("WidgetInspectorService.instance." + methodName + "()", null, null);
  }

  CompletableFuture<JsonElement> invokeServiceExtensionNoGroup(String methodName, JsonObject params) {
    return invokeServiceExtensionHelper(methodName, params);
  }

  private CompletableFuture<JsonElement> invokeServiceExtensionHelper(String methodName, JsonObject params) {
    // Workaround null values turning into the string "null" when using the VM Service extension protocol.
    final ArrayList<String> keysToRemove = new ArrayList<>();

    for (String key : params.keySet()) {
      if (params.get(key).isJsonNull()) {
        keysToRemove.add(key);
      }
    }
    for (String key : keysToRemove) {
      params.remove(key);
    }
    final CompletableFuture<JsonElement> ret = new CompletableFuture<>();
    vmService.callServiceExtension(
      getInspectorLibrary().getIsolateId(), ServiceExtensions.inspectorPrefix + methodName, params,
      new ServiceExtensionConsumer() {
        @Override
        public void received(JsonObject object) {
          if (object == null) {
            ret.complete(null);
          }
          else {
            ret.complete(object.get("result"));
          }
        }

        @Override
        public void onError(RPCError error) {
          ret.completeExceptionally(new RuntimeException("RPCError calling " + methodName + ": " + error.getMessage()));
        }
      }
    );
    return ret;
  }

  /**
   * Class managing a group of inspector objects that can be freed by
   * a single call to dispose().
   * After dispose is called, all pending requests made with the ObjectGroup
   * will be skipped. This means that clients should not have to write any
   * special logic to handle orphaned requests.
   * <p>
   * safeWhenComplete is the recommended way to await futures returned by the
   * ObjectGroup as with that method the callback will be skipped if the
   * ObjectGroup is disposed making it easy to get the correct behavior of
   * skipping orphaned requests. Otherwise, code needs to handle getting back
   * futures that return null values for requests from disposed ObjectGroup
   * objects.
   */
  public class ObjectGroup implements Disposable {
    final InspectorService service;
    /**
     * Object group all objects in this arena are allocated with.
     */
    final String groupName;

    volatile boolean disposed;
    final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    private ObjectGroup(InspectorService service, String debugName) {
      this.service = service;
      this.groupName = debugName + "_" + nextGroupId;
      nextGroupId++;
    }

    public InspectorService getInspectorService() {
      return service;
    }

    /**
     * Once an ObjectGroup has been disposed, all methods returning
     * DiagnosticsNode objects will return a placeholder dummy node and all methods
     * returning lists or maps will return empty lists and all other methods will
     * return null. Generally code should never call methods on a disposed object
     * group but sometimes due to chained futures that can be difficult to avoid
     * and it is simpler return an empty result that will be ignored anyway than to
     * attempt carefully cancel futures.
     */
    @Override
    public void dispose() {
      if (disposed) {
        return;
      }
      lock.writeLock().lock();
      invokeVoidServiceMethod("disposeGroup", groupName);
      disposed = true;
      lock.writeLock().unlock();
    }

    private <T> CompletableFuture<T> nullIfDisposed(Supplier<CompletableFuture<T>> supplier) {
      lock.readLock().lock();
      if (disposed) {
        lock.readLock().unlock();
        return CompletableFuture.completedFuture(null);
      }

      try {
        return supplier.get();
      }
      finally {
        lock.readLock().unlock();
      }
    }

    private <T> T nullValueIfDisposed(Supplier<T> supplier) {
      lock.readLock().lock();
      if (disposed) {
        lock.readLock().unlock();
        return null;
      }

      try {
        return supplier.get();
      }
      finally {
        lock.readLock().unlock();
      }
    }

    private void skipIfDisposed(Runnable runnable) {
      lock.readLock().lock();
      if (disposed) {
        return;
      }

      try {
        runnable.run();
      }
      finally {
        lock.readLock().unlock();
      }
    }

    public CompletableFuture<XSourcePosition> getPropertyLocation(InstanceRef instanceRef, String name) {
      return nullIfDisposed(() -> getInstance(instanceRef)
        .thenComposeAsync((Instance instance) -> nullValueIfDisposed(() -> getPropertyLocationHelper(instance.getClassRef(), name))));
    }

    public CompletableFuture<XSourcePosition> getPropertyLocationHelper(ClassRef classRef, String name) {
      return nullIfDisposed(() -> inspectorLibrary.getClass(classRef, this).thenComposeAsync((ClassObj clazz) -> {
        return nullIfDisposed(() -> {
          for (FuncRef f : clazz.getFunctions()) {
            // TODO(pq): check for private properties that match name.
            if (f.getName().equals(name)) {
              return inspectorLibrary.getFunc(f, this).thenComposeAsync((Func func) -> nullIfDisposed(() -> {
                final SourceLocation location = func.getLocation();
                return inspectorLibrary.getSourcePosition(debugProcess, location.getScript(), location.getTokenPos(), this);
              }));
            }
          }
          final ClassRef superClass = clazz.getSuperClass();
          return superClass == null ? CompletableFuture.completedFuture(null) : getPropertyLocationHelper(superClass, name);
        });
      }));
    }

    public CompletableFuture<DiagnosticsNode> getRoot(FlutterTreeType type) {
      // There is no excuse to call this method on a disposed group.
      assert (!disposed);
      switch (type) {
        case widget:
          return getRootWidget();
        case renderObject:
          return getRootRenderObject();
      }
      throw new RuntimeException("Unexpected FlutterTreeType");
    }

    /**
     * Invokes a static method on the WidgetInspectorService class passing in the specified
     * arguments.
     * <p>
     * Intent is we could refactor how the API is invoked by only changing this call.
     */
    CompletableFuture<InstanceRef> invokeEval(String methodName) {
      return nullIfDisposed(() -> invokeEval(methodName, groupName));
    }

    CompletableFuture<InstanceRef> invokeEval(String methodName, String arg1) {
      return nullIfDisposed(
        () -> getInspectorLibrary().eval("WidgetInspectorService.instance." + methodName + "(\"" + arg1 + "\")", null, this));
    }

    CompletableFuture<JsonElement> invokeVmServiceExtension(String methodName) {
      return invokeVmServiceExtension(methodName, groupName);
    }

    CompletableFuture<JsonElement> invokeVmServiceExtension(String methodName, String objectGroup) {
      final JsonObject params = new JsonObject();
      params.addProperty("objectGroup", objectGroup);
      return invokeVmServiceExtension(methodName, params);
    }

    CompletableFuture<JsonElement> invokeVmServiceExtension(String methodName, String arg, String objectGroup) {
      final JsonObject params = new JsonObject();
      params.addProperty("arg", arg);
      params.addProperty("objectGroup", objectGroup);
      return invokeVmServiceExtension(methodName, params);
    }

    // All calls to invokeVmServiceExtension bottom out to this call.
    CompletableFuture<JsonElement> invokeVmServiceExtension(String methodName, JsonObject paramsMap) {
      return getInspectorLibrary().addRequest(
        this,
        methodName,
        () -> {
          return invokeServiceExtensionHelper(methodName, paramsMap);
        }
      );
    }

    CompletableFuture<JsonElement> invokeVmServiceExtension(String methodName, InspectorInstanceRef arg) {
      if (arg == null || arg.getId() == null) {
        return invokeVmServiceExtension(methodName, null, groupName);
      }
      return invokeVmServiceExtension(methodName, arg.getId(), groupName);
    }

    private void addLocationToParams(Location location, JsonObject params) {
      if (location == null) return;
      params.addProperty("file", location.getPath());
      params.addProperty("line", location.getLine());
      params.addProperty("column", location.getColumn());
    }

    public CompletableFuture<ArrayList<DiagnosticsNode>> getElementsAtLocation(Location location, int count) {
      final JsonObject params = new JsonObject();
      addLocationToParams(location, params);
      params.addProperty("count", count);
      params.addProperty("groupName", groupName);

      return parseDiagnosticsNodesDaemon(
        inspectorLibrary.invokeServiceMethod("ext.flutter.inspector.getElementsAtLocation", params).thenApplyAsync((o) -> {
          if (o == null) return null;
          return o.get("result");
        }), null);
    }

    public CompletableFuture<ArrayList<DiagnosticsNode>> getBoundingBoxes(DiagnosticsNode root, DiagnosticsNode target) {
      final JsonObject params = new JsonObject();
      if (root == null || target == null || root.getValueRef() == null || target.getValueRef() == null) {
        return CompletableFuture.completedFuture(new ArrayList<>());
      }
      params.addProperty("rootId", root.getValueRef().getId());
      params.addProperty("targetId", target.getValueRef().getId());
      params.addProperty("groupName", groupName);

      return parseDiagnosticsNodesDaemon(
        inspectorLibrary.invokeServiceMethod("ext.flutter.inspector.getBoundingBoxes", params).thenApplyAsync((o) -> {
          if (o == null) return null;
          return o.get("result");
        }), null);
    }

    public CompletableFuture<ArrayList<DiagnosticsNode>> hitTest(DiagnosticsNode root,
                                                                 double dx,
                                                                 double dy,
                                                                 String file,
                                                                 int startLine,
                                                                 int endLine) {
      final JsonObject params = new JsonObject();
      if (root == null || root.getValueRef() == null) {
        return CompletableFuture.completedFuture(new ArrayList<>());
      }
      params.addProperty("id", root.getValueRef().getId());
      params.addProperty("dx", dx);
      params.addProperty("dy", dy);
      if (file != null) {
        params.addProperty("file", file);
      }

      if (startLine >= 0 && endLine >= 0) {
        params.addProperty("startLine", startLine);
        params.addProperty("endLine", endLine);
      }

      params.addProperty("groupName", groupName);

      return parseDiagnosticsNodesDaemon(
        inspectorLibrary.invokeServiceMethod("ext.flutter.inspector.hitTest", params).thenApplyAsync((o) -> {
          if (o == null) return null;
          return o.get("result");
        }), null);
    }

    public CompletableFuture<Boolean> setColorProperty(DiagnosticsNode target, Color color) {
      // We implement this method directly here rather than landing it in
      // package:flutter as the right long term solution is to optimize hot reloads of single property changes.

      // This method only supports Container and Text widgets and will intentionally fail for all other cases.

      if (target == null || target.getValueRef() == null || color == null) return CompletableFuture.completedFuture(false);

      String command =
        "final object = WidgetInspectorService.instance.toObject('" +
        target.getValueRef().getId() +
        "');" +
        "if (object is! Element) return false;\n" +
        "final Element element = object;\n" +
        "final color = Color.fromARGB(" +
        color.getAlpha() +
        "," +
        color.getRed() +
        "," +
        color.getGreen() +
        "," +
        color.getBlue() +
        ");\n" +
        "RenderObject render = element?.renderObject;\n" +
        "\n" +
        "if (render is RenderParagraph) {\n" +
        "  RenderParagraph paragraph = render;\n" +
        "  final InlineSpan inlineSpan = paragraph.text;\n" +
        "  if (inlineSpan is! TextSpan) return false;\n" +
        "  final TextSpan existing = inlineSpan;\n" +
        "  paragraph.text = TextSpan(text: existing.text,\n" +
        "    children: existing.children,\n" +
        "    style: existing.style.copyWith(color: color),\n" +
        "    recognizer: existing.recognizer,\n" +
        "    semanticsLabel: existing.semanticsLabel,\n" +
        "  );\n" +
        "  return true;\n" +
        "} else {\n" +
        "  RenderDecoratedBox findFirstMatching(Element root) {\n" +
        "    RenderDecoratedBox match = null;\n" +
        "    void _matchHelper(Element e) {\n" +
        "      if (match != null || !identical(e, root) && _isLocalCreationLocation(e)) return;\n" +
        "      final r = e.renderObject;\n" +
        "      if (r is RenderDecoratedBox) {\n" +
        "        match = r;\n" +
        "        return;\n" +
        "      }\n" +
        "      e.visitChildElements(_matchHelper);\n" +
        "    }\n" +
        "    _matchHelper(root);\n" +
        "    return match;\n" +
        "  }\n" +
        "\n" +
        "  final RenderDecoratedBox render = findFirstMatching(element);\n" +
        "  if (render != null) {\n" +
        "    final BoxDecoration existingDecoration = render.decoration;\n" +
        "    BoxDecoration decoration;\n" +
        "    if (existingDecoration is BoxDecoration) {\n" +
        "      decoration = existingDecoration.copyWith(color: color);\n" +
        "    } else if (existingDecoration == null) {\n" +
        "      decoration = BoxDecoration(color: color);\n" +
        "    }\n" +
        "    if (decoration != null) {\n" +
        "      render.decoration = decoration;\n" +
        "      return true;\n" +
        "    }\n" +
        "  }\n" +
        "}\n" +
        "return false;\n";

      return evaluateCustomApiHelper(command, new HashMap<>()).thenApplyAsync((instanceRef) -> {
        return instanceRef != null && "true".equals(instanceRef.getValueAsString());
      });
    }

    private CompletableFuture<InstanceRef> evaluateCustomApiHelper(String command, Map<String, String> scope) {
      // Avoid running command if we interrupted executing code as results will
      // be weird. Repeatedly run the command until we hit idle.
      // We cannot execute the command at a later point due to eval bugs where
      // the VM crashes executing a closure created by eval asynchronously.
      if (isDisposed()) return CompletableFuture.completedFuture(null);

      final ArrayList<String> lines = new ArrayList<>();
      lines.add("((){");
      lines.add("if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.idle) return null;");


      final String[] commandLines = command.split("\n");
      for (String l : commandLines) {
        lines.add(l);
      }
      lines.add(")()");

      // Strip out line breaks as that makes the VM evaluate expression api unhappy.
      final String expression = Joiner.on("").join(lines);
      return evalWithRetry(expression, scope);
    }

    private CompletableFuture<InstanceRef> evalWithRetry(String expression, Map<String, String> scope) {
      if (isDisposed()) return CompletableFuture.completedFuture(null);

      return inspectorLibrary.eval(expression, scope, this).thenComposeAsync(
        (instanceRef) -> {
          if (instanceRef == null) {
            // A null value indicates the request was cancelled.
            return CompletableFuture.completedFuture(instanceRef);
          }
          if (instanceRef.isNull()) {
            // An InstanceRef with an explicitly null return value indicates we should issue the request again.
            return evalWithRetry(expression, scope);
          }
          return CompletableFuture.completedFuture(instanceRef);
        }
      );
    }

    public CompletableFuture<InteractiveScreenshot> getScreenshotAtLocation(
      Location location,
      int count,
      int width,
      int height,
      double maxPixelRatio) {
      final JsonObject params = new JsonObject();
      addLocationToParams(location, params);
      params.addProperty("count", count);
      params.addProperty("width", width);
      params.addProperty("height", height);
      params.addProperty("maxPixelRatio", maxPixelRatio);
      params.addProperty("groupName", groupName);
      return nullIfDisposed(() -> {
        return inspectorLibrary.invokeServiceMethod("ext.flutter.inspector.screenshotAtLocation", params).thenApplyAsync(
          (JsonObject response) -> {
            if (response == null || response.get("result").isJsonNull()) {
              // No screenshot available.
              return null;
            }
            final JsonObject result = response.getAsJsonObject("result");
            Screenshot screenshot = null;
            final JsonElement screenshotJson = result.get("screenshot");
            if (screenshotJson != null && !screenshotJson.isJsonNull()) {
              screenshot = getScreenshotFromJson(screenshotJson.getAsJsonObject());
            }
            return new InteractiveScreenshot(
              screenshot,
              parseDiagnosticsNodesHelper(result.get("boxes"), null),
              parseDiagnosticsNodesHelper(result.get("elements"), null)
            );
          });
      });
    }

    public CompletableFuture<Screenshot> getScreenshot(InspectorInstanceRef ref, int width, int height, double maxPixelRatio) {
      final JsonObject params = new JsonObject();
      params.addProperty("width", width);
      params.addProperty("height", height);
      params.addProperty("maxPixelRatio", maxPixelRatio);
      params.addProperty("id", ref.getId());

      return nullIfDisposed(
        () -> inspectorLibrary.invokeServiceMethod("ext.flutter.inspector.screenshot", params).thenApplyAsync((JsonObject response) -> {
          if (response == null || response.get("result").isJsonNull()) {
            // No screenshot avaiable.
            return null;
          }
          final JsonObject result = response.getAsJsonObject("result");

          return getScreenshotFromJson(result);
        }));
    }

    @NotNull
    private Screenshot getScreenshotFromJson(JsonObject result) {
      final String imageString = result.getAsJsonPrimitive("image").getAsString();
      // create a buffered image
      final Base64.Decoder decoder = Base64.getDecoder();
      final byte[] imageBytes = decoder.decode(imageString);
      final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(imageBytes);
      BufferedImage image = null;
      try {
        image = ImageIO.read(byteArrayInputStream);
        byteArrayInputStream.close();
      }
      catch (IOException e) {
        throw new RuntimeException("Error decoding image: " + e.getMessage());
      }

      final TransformedRect transformedRect = new TransformedRect(result.getAsJsonObject("transformedRect"));
      return new Screenshot(image, transformedRect);
    }

    CompletableFuture<InstanceRef> invokeEval(String methodName, InspectorInstanceRef arg) {
      return nullIfDisposed(() -> {
        if (arg == null || arg.getId() == null) {
          return getInspectorLibrary().eval("WidgetInspectorService.instance." + methodName + "(null, \"" + groupName + "\")", null, this);
        }
        return getInspectorLibrary()
          .eval("WidgetInspectorService.instance." + methodName + "(\"" + arg.getId() + "\", \"" + groupName + "\")", null, this);
      });
    }

    /**
     * Call a service method passing in an VM Service instance reference.
     * <p>
     * This call is useful when receiving an "inspect" event from the
     * VM Service and future use cases such as inspecting a Widget from the
     * IntelliJ watch window.
     * <p>
     * This method will always need to use the VM Service as the input
     * parameter is an VM Service InstanceRef..
     */
    CompletableFuture<InstanceRef> invokeServiceMethodOnRefEval(String methodName, InstanceRef arg) {
      return nullIfDisposed(() -> {
        final HashMap<String, String> scope = new HashMap<>();
        if (arg == null) {
          return getInspectorLibrary().eval("WidgetInspectorService.instance." + methodName + "(null, \"" + groupName + "\")", scope, this);
        }
        scope.put("arg1", arg.getId());
        return getInspectorLibrary().eval("WidgetInspectorService.instance." + methodName + "(arg1, \"" + groupName + "\")", scope, this);
      });
    }

    CompletableFuture<DiagnosticsNode> parseDiagnosticsNodeVmService(CompletableFuture<InstanceRef> instanceRefFuture) {
      return nullIfDisposed(() -> instanceRefFuture.thenComposeAsync(this::parseDiagnosticsNodeVmService));
    }

    /**
     * Returns a CompletableFuture with a Map of property names to VM Service
     * InstanceRef objects. This method is shorthand for individually evaluating
     * each of the getters specified by property names.
     * <p>
     * It would be nice if the VM Service protocol provided a built in method
     * to get InstanceRef objects for a list of properties but this is
     * sufficient although slightly less efficient. The VM Service protocol
     * does provide fast access to all fields as part of an Instance object
     * but that is inadequate as for many Flutter data objects that we want
     * to display visually we care about properties that are not necessarily
     * fields.
     * <p>
     * The future will immediately complete to null if the inspectorInstanceRef is null.
     */
    public CompletableFuture<Map<String, InstanceRef>> getDartObjectProperties(
      InspectorInstanceRef inspectorInstanceRef, final String[] propertyNames) {
      return nullIfDisposed(
        () -> toVmServiceInstanceRef(inspectorInstanceRef).thenComposeAsync((InstanceRef instanceRef) -> nullIfDisposed(() -> {
          final StringBuilder sb = new StringBuilder();
          final List<String> propertyAccessors = new ArrayList<>();
          final String objectName = "that";
          for (String propertyName : propertyNames) {
            propertyAccessors.add(objectName + "." + propertyName);
          }
          sb.append("[");
          sb.append(Joiner.on(',').join(propertyAccessors));
          sb.append("]");
          final Map<String, String> scope = new HashMap<>();
          scope.put(objectName, instanceRef.getId());
          return getInstance(inspectorLibrary.eval(sb.toString(), scope, this)).thenApplyAsync(
            (Instance instance) -> nullValueIfDisposed(() -> {
              // We now have an instance object that is a Dart array of all the
              // property values. Convert it back to a map from property name to
              // property values.

              final Map<String, InstanceRef> properties = new HashMap<>();
              final ElementList<InstanceRef> values = instance.getElements();
              assert (values.size() == propertyNames.length);
              for (int i = 0; i < propertyNames.length; ++i) {
                properties.put(propertyNames[i], values.get(i));
              }
              return properties;
            }));
        })));
    }

    public CompletableFuture<InstanceRef> toVmServiceInstanceRef(InspectorInstanceRef inspectorInstanceRef) {
      return nullIfDisposed(() -> invokeEval("toObject", inspectorInstanceRef));
    }

    private CompletableFuture<Instance> getInstance(InstanceRef instanceRef) {
      return nullIfDisposed(() -> getInspectorLibrary().getInstance(instanceRef, this));
    }

    CompletableFuture<Instance> getInstance(CompletableFuture<InstanceRef> instanceRefFuture) {
      return nullIfDisposed(() -> instanceRefFuture.thenComposeAsync(this::getInstance));
    }

    CompletableFuture<DiagnosticsNode> parseDiagnosticsNodeVmService(InstanceRef instanceRef) {
      return nullIfDisposed(() -> instanceRefToJson(instanceRef).thenApplyAsync(this::parseDiagnosticsNodeHelper));
    }

    CompletableFuture<DiagnosticsNode> parseDiagnosticsNodeDaemon(CompletableFuture<JsonElement> json) {
      return nullIfDisposed(() -> json.thenApplyAsync(this::parseDiagnosticsNodeHelper));
    }

    DiagnosticsNode parseDiagnosticsNodeHelper(JsonElement jsonElement) {
      return nullValueIfDisposed(() -> {
        if (jsonElement == null || jsonElement.isJsonNull()) {
          return null;
        }
        return new DiagnosticsNode(jsonElement.getAsJsonObject(), this, false, null);
      });
    }

    CompletableFuture<JsonElement> instanceRefToJson(CompletableFuture<InstanceRef> instanceRefFuture) {
      return nullIfDisposed(() -> instanceRefFuture.thenComposeAsync(this::instanceRefToJson));
    }

    /**
     * Requires that the InstanceRef is really referring to a String that is valid JSON.
     */
    CompletableFuture<JsonElement> instanceRefToJson(InstanceRef instanceRef) {
      if (instanceRef.getValueAsString() != null && !instanceRef.getValueAsStringIsTruncated()) {
        // In some situations, the string may already be fully populated.
        final JsonElement json = new JsonParser().parse(instanceRef.getValueAsString());
        return CompletableFuture.completedFuture(json);
      }
      else {
        // Otherwise, retrieve the full value of the string.
        return nullIfDisposed(() -> getInspectorLibrary().getInstance(instanceRef, this).thenApplyAsync((Instance instance) -> {
          return nullValueIfDisposed(() -> {
            final String json = instance.getValueAsString();
            return new JsonParser().parse(json);
          });
        }));
      }
    }

    CompletableFuture<ArrayList<DiagnosticsNode>> parseDiagnosticsNodesVmService(InstanceRef instanceRef, DiagnosticsNode parent) {
      return nullIfDisposed(() -> instanceRefToJson(instanceRef).thenApplyAsync((JsonElement jsonElement) -> {
        return nullValueIfDisposed(() -> {
          final JsonArray jsonArray = jsonElement != null ? jsonElement.getAsJsonArray() : null;
          return parseDiagnosticsNodesHelper(jsonArray, parent);
        });
      }));
    }

    ArrayList<DiagnosticsNode> parseDiagnosticsNodesHelper(JsonElement jsonObject, DiagnosticsNode parent) {
      return parseDiagnosticsNodesHelper(jsonObject != null && !jsonObject.isJsonNull() ? jsonObject.getAsJsonArray() : null, parent);
    }

    ArrayList<DiagnosticsNode> parseDiagnosticsNodesHelper(JsonArray jsonArray, DiagnosticsNode parent) {
      return nullValueIfDisposed(() -> {
        if (jsonArray == null) {
          return null;
        }
        final ArrayList<DiagnosticsNode> nodes = new ArrayList<>();
        for (JsonElement element : jsonArray) {
          nodes.add(new DiagnosticsNode(element.getAsJsonObject(), this, false, parent));
        }
        return nodes;
      });
    }

    /**
     * Converts an inspector ref to value suitable for use by generic intellij
     * debugging tools.
     * <p>
     * Warning: FlutterVmServiceValue references do not make any lifetime guarantees
     * so code keeping them around for a long period of time must be prepared to
     * handle reference expiration gracefully.
     */
    public CompletableFuture<DartVmServiceValue> toDartVmServiceValue(InspectorInstanceRef inspectorInstanceRef) {
      return invokeEval("toObject", inspectorInstanceRef).thenApplyAsync(
        (InstanceRef instanceRef) -> nullValueIfDisposed(() -> {
          //noinspection CodeBlock2Expr
          return new DartVmServiceValue(debugProcess, inspectorLibrary.getIsolateId(), "inspectedObject", instanceRef, null, null, false);
        }));
    }

    /**
     * Converts an inspector ref to value suitable for use by generic intellij
     * debugging tools.
     * <p>
     * Warning: FlutterVmServiceValue references do not make any lifetime guarantees
     * so code keeping them around for a long period of time must be prepared to
     * handle reference expiration gracefully.
     */
    public CompletableFuture<DartVmServiceValue> toDartVmServiceValueForSourceLocation(InspectorInstanceRef inspectorInstanceRef) {
      return invokeEval("toObjectForSourceLocation", inspectorInstanceRef).thenApplyAsync(
        (InstanceRef instanceRef) -> nullValueIfDisposed(() -> {
          //noinspection CodeBlock2Expr
          return new DartVmServiceValue(debugProcess, inspectorLibrary.getIsolateId(), "inspectedObject", instanceRef, null, null, false);
        }));
    }

    CompletableFuture<ArrayList<DiagnosticsNode>> parseDiagnosticsNodesVmService(CompletableFuture<InstanceRef> instanceRefFuture,
                                                                                 DiagnosticsNode parent) {
      return nullIfDisposed(
        () -> instanceRefFuture.thenComposeAsync((instanceRef) -> parseDiagnosticsNodesVmService(instanceRef, parent)));
    }

    CompletableFuture<ArrayList<DiagnosticsNode>> parseDiagnosticsNodesDaemon(CompletableFuture<JsonElement> jsonFuture,
                                                                              DiagnosticsNode parent) {
      return nullIfDisposed(() -> jsonFuture.thenApplyAsync((json) -> parseDiagnosticsNodesHelper(json, parent)));
    }

    CompletableFuture<ArrayList<DiagnosticsNode>> getChildren(InspectorInstanceRef instanceRef,
                                                              boolean summaryTree,
                                                              DiagnosticsNode parent) {
      if (isDetailsSummaryViewSupported()) {
        return getListHelper(instanceRef, summaryTree ? "getChildrenSummaryTree" : "getChildrenDetailsSubtree", parent);
      }
      else {
        return getListHelper(instanceRef, "getChildren", parent);
      }
    }

    CompletableFuture<ArrayList<DiagnosticsNode>> getProperties(InspectorInstanceRef instanceRef) {
      return getListHelper(instanceRef, "getProperties", null);
    }

    private CompletableFuture<ArrayList<DiagnosticsNode>> getListHelper(
      InspectorInstanceRef instanceRef, String methodName, DiagnosticsNode parent) {
      return nullIfDisposed(() -> {
        if (useServiceExtensionApi()) {
          return parseDiagnosticsNodesDaemon(invokeVmServiceExtension(methodName, instanceRef), parent);
        }
        else {
          return parseDiagnosticsNodesVmService(invokeEval(methodName, instanceRef), parent);
        }
      });
    }

    public CompletableFuture<DiagnosticsNode> invokeServiceMethodReturningNode(String methodName) {
      return nullIfDisposed(() -> {
        if (useServiceExtensionApi()) {
          return parseDiagnosticsNodeDaemon(invokeVmServiceExtension(methodName));
        }
        else {
          return parseDiagnosticsNodeVmService(invokeEval(methodName));
        }
      });
    }

    public CompletableFuture<DiagnosticsNode> invokeServiceMethodReturningNode(String methodName, InspectorInstanceRef ref) {
      return nullIfDisposed(() -> {
        if (useServiceExtensionApi()) {
          return parseDiagnosticsNodeDaemon(invokeVmServiceExtension(methodName, ref));
        }
        else {
          return parseDiagnosticsNodeVmService(invokeEval(methodName, ref));
        }
      });
    }

    public CompletableFuture<Void> invokeVoidServiceMethod(String methodName, String arg1) {
      return nullIfDisposed(() -> {
        if (useServiceExtensionApi()) {
          return invokeVmServiceExtension(methodName, arg1).thenApply((ignored) -> null);
        }
        else {
          return invokeEval(methodName, arg1).thenApply((ignored) -> null);
        }
      });
    }

    public CompletableFuture<Void> invokeVoidServiceMethod(String methodName, InspectorInstanceRef ref) {
      return nullIfDisposed(() -> {
        if (useServiceExtensionApi()) {
          return invokeVmServiceExtension(methodName, ref).thenApply((ignored) -> null);
        }
        else {
          return invokeEval(methodName, ref).thenApply((ignored) -> null);
        }
      });
    }

    public CompletableFuture<DiagnosticsNode> getRootWidget() {
      return invokeServiceMethodReturningNode(isDetailsSummaryViewSupported() ? "getRootWidgetSummaryTree" : "getRootWidget");
    }

    public CompletableFuture<DiagnosticsNode> getElementForScreenshot() {
      return invokeServiceMethodReturningNode("getElementForScreenshot");
    }

    public CompletableFuture<DiagnosticsNode> getSummaryTreeWithoutIds() {
      return parseDiagnosticsNodeDaemon(invokeVmServiceExtension("getRootWidgetSummaryTree", new JsonObject()));
    }

    public CompletableFuture<DiagnosticsNode> getRootRenderObject() {
      assert (!disposed);
      return invokeServiceMethodReturningNode("getRootRenderObject");
    }

    public CompletableFuture<ArrayList<DiagnosticsPathNode>> getParentChain(DiagnosticsNode target) {
      return nullIfDisposed(() -> {
        if (useServiceExtensionApi()) {
          return parseDiagnosticsPathDaemon(invokeVmServiceExtension("getParentChain", target.getValueRef()));
        }
        else {
          return parseDiagnosticsPathVmService(invokeEval("getParentChain", target.getValueRef()));
        }
      });
    }

    CompletableFuture<ArrayList<DiagnosticsPathNode>> parseDiagnosticsPathVmService(CompletableFuture<InstanceRef> instanceRefFuture) {
      return nullIfDisposed(() -> instanceRefFuture.thenComposeAsync(this::parseDiagnosticsPathVmService));
    }

    private CompletableFuture<ArrayList<DiagnosticsPathNode>> parseDiagnosticsPathVmService(InstanceRef pathRef) {
      return nullIfDisposed(() -> instanceRefToJson(pathRef).thenApplyAsync(this::parseDiagnosticsPathHelper));
    }

    CompletableFuture<ArrayList<DiagnosticsPathNode>> parseDiagnosticsPathDaemon(CompletableFuture<JsonElement> jsonFuture) {
      return nullIfDisposed(() -> jsonFuture.thenApplyAsync(this::parseDiagnosticsPathHelper));
    }

    private ArrayList<DiagnosticsPathNode> parseDiagnosticsPathHelper(JsonElement jsonElement) {
      return nullValueIfDisposed(() -> {
        final JsonArray jsonArray = jsonElement.getAsJsonArray();
        final ArrayList<DiagnosticsPathNode> pathNodes = new ArrayList<>();
        for (JsonElement element : jsonArray) {
          pathNodes.add(new DiagnosticsPathNode(element.getAsJsonObject(), this));
        }
        return pathNodes;
      });
    }

    public CompletableFuture<DiagnosticsNode> getSelection(DiagnosticsNode previousSelection, FlutterTreeType treeType, boolean localOnly) {
      // There is no reason to allow calling this method on a disposed group.
      assert (!disposed);
      return nullIfDisposed(() -> {
        CompletableFuture<DiagnosticsNode> result = null;
        final InspectorInstanceRef previousSelectionRef = previousSelection != null ? previousSelection.getDartDiagnosticRef() : null;

        switch (treeType) {
          case widget:
            result = invokeServiceMethodReturningNode(localOnly ? "getSelectedSummaryWidget" : "getSelectedWidget", previousSelectionRef);
            break;
          case renderObject:
            result = invokeServiceMethodReturningNode("getSelectedRenderObject", previousSelectionRef);
            break;
        }
        return result.thenApplyAsync((DiagnosticsNode newSelection) -> nullValueIfDisposed(() -> {
          if (newSelection != null && newSelection.getDartDiagnosticRef().equals(previousSelectionRef)) {
            return previousSelection;
          }
          else {
            return newSelection;
          }
        }));
      });
    }

    public void setSelection(InspectorInstanceRef selection, boolean uiAlreadyUpdated, boolean textEditorUpdated) {
      if (disposed) {
        return;
      }
      if (selection == null || selection.getId() == null) {
        return;
      }
      if (useServiceExtensionApi()) {
        handleSetSelectionDaemon(invokeVmServiceExtension("setSelectionById", selection), uiAlreadyUpdated, textEditorUpdated);
      }
      else {
        handleSetSelectionVmService(invokeEval("setSelectionById", selection), uiAlreadyUpdated, textEditorUpdated);
      }
    }

    public void setSelection(Location location, boolean uiAlreadyUpdated, boolean textEditorUpdated) {
      if (disposed) {
        return;
      }
      if (location == null) {
        return;
      }
      if (useServiceExtensionApi()) {
        JsonObject params = new JsonObject();
        addLocationToParams(location, params);
        handleSetSelectionDaemon(invokeVmServiceExtension("setSelectionByLocation", params), uiAlreadyUpdated, textEditorUpdated);
      }
      // skip if the vm service is expected to be used directly.
    }

    /**
     * Helper when we need to set selection given an VM Service InstanceRef
     * instead of an InspectorInstanceRef.
     */
    public void setSelection(InstanceRef selection, boolean uiAlreadyUpdated, boolean textEditorUpdated) {
      // There is no excuse for calling setSelection using a disposed ObjectGroup.
      assert (!disposed);
      // This call requires the VM Service protocol as an VM Service InstanceRef is specified.
      handleSetSelectionVmService(invokeServiceMethodOnRefEval("setSelection", selection), uiAlreadyUpdated, textEditorUpdated);
    }

    private void handleSetSelectionVmService(CompletableFuture<InstanceRef> setSelectionResult,
                                             boolean uiAlreadyUpdated,
                                             boolean textEditorUpdated) {
      // TODO(jacobr): we need to cancel if another inspect request comes in while we are trying this one.
      skipIfDisposed(() -> setSelectionResult.thenAcceptAsync((InstanceRef instanceRef) -> skipIfDisposed(() -> {
        handleSetSelectionHelper("true".equals(instanceRef.getValueAsString()), uiAlreadyUpdated, textEditorUpdated);
      })));
    }

    private void handleSetSelectionHelper(boolean selectionChanged, boolean uiAlreadyUpdated, boolean textEditorUpdated) {
      if (selectionChanged) {
        notifySelectionChanged(uiAlreadyUpdated, textEditorUpdated);
      }
    }

    private void handleSetSelectionDaemon(CompletableFuture<JsonElement> setSelectionResult,
                                          boolean uiAlreadyUpdated,
                                          boolean textEditorUpdated) {
      skipIfDisposed(() ->
                       // TODO(jacobr): we need to cancel if another inspect request comes in while we are trying this one.
                       setSelectionResult.thenAcceptAsync(
                         (JsonElement json) -> skipIfDisposed(
                           () -> handleSetSelectionHelper(json.getAsBoolean(), uiAlreadyUpdated, textEditorUpdated)))
      );
    }

    public CompletableFuture<Map<String, InstanceRef>> getEnumPropertyValues(InspectorInstanceRef ref) {
      return nullIfDisposed(() -> {
        if (ref == null || ref.getId() == null) {
          return CompletableFuture.completedFuture(null);
        }
        return getInstance(toVmServiceInstanceRef(ref))
          .thenComposeAsync(
            (Instance instance) -> nullIfDisposed(() -> getInspectorLibrary().getClass(instance.getClassRef(), this).thenApplyAsync(
              (ClassObj clazz) -> nullValueIfDisposed(() -> {
                final Map<String, InstanceRef> properties = new LinkedHashMap<>();
                for (FieldRef field : clazz.getFields()) {
                  final String name = field.getName();
                  if (name.startsWith("_")) {
                    // Needed to filter out _deleted_enum_sentinel synthetic property.
                    // If showing private enum values is useful we could special case
                    // just the _deleted_enum_sentinel property name.
                    continue;
                  }
                  if (name.equals("values")) {
                    // Need to filter out the synthetic "values" member.
                    // TODO(jacobr): detect that this properties return type is
                    // different and filter that way.
                    continue;
                  }
                  if (field.isConst() && field.isStatic()) {
                    properties.put(field.getName(), field.getDeclaredType());
                  }
                }
                return properties;
              })
            )));
      });
    }

    public CompletableFuture<DiagnosticsNode> getDetailsSubtree(DiagnosticsNode node) {
      if (node == null) {
        return CompletableFuture.completedFuture(null);
      }
      return nullIfDisposed(() -> invokeServiceMethodReturningNode("getDetailsSubtree", node.getDartDiagnosticRef()));
    }

    public XDebuggerEditorsProvider getEditorsProvider() {
      return InspectorService.this.getDebugProcess().getEditorsProvider();
    }

    FlutterApp getApp() {
      return InspectorService.this.getApp();
    }

    /**
     * Await a Future invoking the callback on completion on the UI thread only if the
     * rhis ObjectGroup is still alive when the Future completes.
     */
    public <T> void safeWhenComplete(CompletableFuture<T> future, BiConsumer<? super T, ? super Throwable> action) {
      if (future == null) {
        return;
      }
      future.whenCompleteAsync(
        (T value, Throwable throwable) -> skipIfDisposed(() -> {
          ApplicationManager.getApplication().invokeLater(() -> {
            action.accept(value, throwable);
          });
        })
      );
    }

    public boolean isDisposed() {
      return disposed;
    }
  }

  public static String getFileUriPrefix() {
    return SystemInfo.isWindows ? "file:///" : "file://";
  }

  // TODO(jacobr): remove this method as soon as the
  // track-widget-creation kernel transformer is fixed to return paths instead
  // of URIs.
  public static String toSourceLocationUri(String path) {
    return getFileUriPrefix() + path;
  }

  public static String fromSourceLocationUri(String path, Project project) {
    final Workspace workspace = WorkspaceCache.getInstance(project).get();
    if (workspace != null) {
      path = workspace.convertPath(path);
    }

    final String filePrefix = getFileUriPrefix();
    return (path.startsWith(filePrefix)) ? path.substring(filePrefix.length()) : path;
  }

  public enum FlutterTreeType {
    widget("Widget"),
    renderObject("Render");
    // TODO(jacobr): add semantics, and layer trees.

    public final String displayName;

    FlutterTreeType(String displayName) {
      this.displayName = displayName;
    }
  }

  public interface InspectorServiceClient {
    void onInspectorSelectionChanged(boolean uiAlreadyUpdated, boolean textEditorUpdated);

    void onFlutterFrame();

    CompletableFuture<?> onForceRefresh();
  }
}