/*
 * DependencyManager.java
 *
 * Copyright (C) 2022 by Posit Software, PBC
 *
 * Unless you have received this program directly from Posit Software pursuant
 * to the terms of a commercial license agreement with Posit Software, then
 * this program is licensed to you under the terms of version 3 of the
 * GNU Affero General Public License. This program is distributed WITHOUT
 * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
 * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
 * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
 *
 */

package org.rstudio.studio.client.common.dependencies;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

import org.rstudio.core.client.CommandWith2Args;
import org.rstudio.core.client.CommandWithArg;
import org.rstudio.core.client.Debug;
import org.rstudio.core.client.StringUtil;
import org.rstudio.core.client.widget.MessageDialog;
import org.rstudio.core.client.widget.Operation;
import org.rstudio.core.client.widget.ProgressIndicator;
import org.rstudio.studio.client.application.events.EventBus;
import org.rstudio.studio.client.common.GlobalDisplay;
import org.rstudio.studio.client.common.GlobalProgressDelayer;
import org.rstudio.studio.client.common.StudioClientCommonConstants;
import org.rstudio.studio.client.common.Value;
import org.rstudio.studio.client.common.dependencies.events.InstallShinyEvent;
import org.rstudio.studio.client.common.dependencies.model.Dependency;
import org.rstudio.studio.client.common.dependencies.model.DependencyList;
import org.rstudio.studio.client.common.dependencies.model.DependencyServerOperations;
import org.rstudio.studio.client.server.ServerError;
import org.rstudio.studio.client.server.ServerRequestCallback;
import org.rstudio.studio.client.workbench.commands.Commands;
import org.rstudio.studio.client.workbench.model.Session;
import org.rstudio.studio.client.workbench.views.console.events.ConsoleActivateEvent;
import org.rstudio.studio.client.workbench.views.jobs.events.JobUpdatedEvent;
import org.rstudio.studio.client.workbench.views.jobs.model.JobConstants;
import org.rstudio.studio.client.workbench.views.jobs.model.JobUpdate;
import org.rstudio.studio.client.workbench.views.packages.events.PackageStateChangedEvent;

import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.JsArray;
import com.google.gwt.core.client.JsArrayString;
import com.google.gwt.event.shared.HandlerRegistration;
import com.google.gwt.user.client.Command;
import com.google.inject.Inject;
import com.google.inject.Singleton;

@Singleton
public class DependencyManager implements InstallShinyEvent.Handler,
                                          PackageStateChangedEvent.Handler
{
   class DependencyRequest
   {
      DependencyRequest(
            String progressCaptionIn,
            String userActionIn,
            String contextIn,
            CommandWith2Args<String,CommandWithArg<Boolean>> userPromptIn,
            List<Dependency> dependenciesIn,
            boolean silentEmbeddedUpdateIn,
            CommandWithArg<Boolean> onCompleteIn)
      {
         progressCaption = progressCaptionIn;
         userAction = userActionIn;
         userPrompt = userPromptIn;
         context = contextIn;
         dependencies = dependenciesIn;
         silentEmbeddedUpdate = silentEmbeddedUpdateIn;
         onComplete = onCompleteIn;
      }
      String progressCaption;
      String userAction;
      String context;
      CommandWith2Args<String,CommandWithArg<Boolean>> userPrompt;
      List<Dependency> dependencies;
      boolean silentEmbeddedUpdate;
      CommandWithArg<Boolean> onComplete;
   }

   @Inject
   public DependencyManager(GlobalDisplay globalDisplay,
                            DependencyServerOperations server,
                            EventBus eventBus,
                            Session session,
                            Commands commands)
   {
      globalDisplay_ = globalDisplay;
      server_ = server;
      satisfied_ = new ArrayList<>();
      requestQueue_ = new LinkedList<>();
      session_ = session;
      commands_ = commands;
      events_ = eventBus;

      eventBus.addHandler(InstallShinyEvent.TYPE, this);
      eventBus.addHandler(PackageStateChangedEvent.TYPE, this);
   }

   public void withDependencies(String progressCaption,
        CommandWith2Args<String,CommandWithArg<Boolean>> userPrompt,
        List<Dependency> dependencies,
        boolean silentEmbeddedUpdate,
        CommandWithArg<Boolean> onComplete)
   {
      withDependencies(progressCaption,
                       null,
                       null,
                       userPrompt,
                       dependencies,
                       silentEmbeddedUpdate,
                       onComplete);
   }

   public void withDependencies(String progressCaption,
                                String userAction,
                                String context,
                                List<Dependency> dependencies,
                                boolean silentEmbeddedUpdate,
                                final CommandWithArg<Boolean> onComplete)
   {
      withDependencies(progressCaption,
                       userAction,
                       context,
                       null,
                       dependencies,
                       silentEmbeddedUpdate,
                       onComplete);
   }

   public void withRoxygen(String progressCaption, String userAction, final Command command)
   {
      withDependencies(
            progressCaption,
            userAction,
            getFeatureDescription("roxygen"),
            getFeatureDependencies("roxygen"),
            false,
            succeeded -> { if (succeeded) command.execute(); });
   }

   public void withThemes(String userAction, final Command command)
   {
      withDependencies(
         constants_.withThemesCaption(),
         userAction,
         getFeatureDescription("theme_conversion"),
         getFeatureDependencies("theme_conversion"),
         true,
         succeeded ->
         {
            if (succeeded)
               command.execute();
         });
   }

   public void withR2D3(String userAction, final Command command)
   {
      withDependencies(
        "R2D3",
         userAction,
         getFeatureDescription("r2d3"),
         getFeatureDependencies("r2d3"),
         true,
         succeeded ->
         {
            if (succeeded)
               command.execute();
         });
   }

   public void withRPlumber(String userAction, final Command command)
   {
      withDependencies(
        "Plumber",
         userAction,
         getFeatureDescription("plumber"),
         getFeatureDependencies("plumber"),
         true,
         new CommandWithArg<Boolean>()
        {
           @Override
           public void execute(Boolean succeeded)
           {
              if (succeeded)
                 command.execute();
           }
        }
      );
   }

   public void withPackrat(String userAction, final Command command)
   {
      withDependencies(
         "Packrat",
         userAction,
         getFeatureDescription("packrat"),
         getFeatureDependencies("packrat"),
         false,
         new CommandWithArg<Boolean>()
         {
            @Override
            public void execute(Boolean succeeded)
            {
               if (succeeded)
                  command.execute();
            }
         });
   }

   public void withRenv(String userAction, final CommandWithArg<Boolean> onSuccess)
   {
      withDependencies(
            "renv",
            userAction,
            getFeatureDescription("renv"),
            getFeatureDependencies("renv"),
            false,
            onSuccess);
   }

   public void withRSConnect(String userAction,
                             boolean requiresRmarkdown,
                             boolean isQuartoManuscript,
                             CommandWith2Args<String, CommandWithArg<Boolean>> userPrompt,
                             final CommandWithArg<Boolean> onCompleted)
   {
      // build dependency array
      List<Dependency> deps = getFeatureDependencies("rsconnect");
      if (requiresRmarkdown)
         deps.addAll(getFeatureDependencies("rmarkdown"));

      withDependencies(
        constants_.publishingPaneHeader(),
        userAction,
        getFeatureDescription("rsconnect"),
        userPrompt,
        deps,
        true, // silently update any embedded packages needed (none at present)
        onCompleted
      );
   }

   public void withConnectCloudDependencies(String userAction, final CommandWithArg<Boolean> onCompleted)
   {
      // build dependency array -- same as rsconnect, but require
      // rsconnect >= 1.8.0 for Connect Cloud support
      List<Dependency> deps = getFeatureDependencies("rsconnect");
      for (int i = 0; i < deps.size(); i++)
      {
         Dependency dep = deps.get(i);
         if (StringUtil.equals(dep.getName(), "rsconnect"))
         {
            // Replace with a fresh Dependency to avoid mutating the
            // shared object in the session's DependencyList cache.
            deps.set(i, Dependency.cranPackage("rsconnect", "1.8.0"));
            break;
         }
      }

      withDependencies(
        constants_.publishingPaneHeader(),
        userAction,
        getFeatureDescription("rsconnect"),
        deps,
        true,
        onCompleted
      );
   }

   public void withRMarkdown(String userAction, final Command command)
   {
      withRMarkdown(constants_.withRMarkdownCaption(), userAction, command);
   }

   public void withRMarkdown(String progressCaption, String userAction,
         final Command command)
   {
     withRMarkdown(
        progressCaption,
        userAction,
        succeeded ->
        {
           if (succeeded)
              command.execute();
        }
     );
   }

   public void withRMarkdown(String progressCaption, String userAction,
         final CommandWithArg<Boolean> command)
   {
     withDependencies(
        progressCaption,
        userAction,
        getFeatureDescription("rmarkdown"),
        getFeatureDependencies("rmarkdown"),
        true, // we want to update to the embedded version if needed
        succeeded ->
        {
           if (succeeded)
           {
              // if we successfully installed the latest R Markdown version,
              // update the session cache of package information.
              session_.getSessionInfo().setKnitParamsAvailable(true);
              session_.getSessionInfo().setRMarkdownPackageAvailable(true);
              session_.getSessionInfo().setKnitWorkingDirAvailable(true);
              session_.getSessionInfo().setPptAvailable(true);

              // restore removed commands
              commands_.knitWithParameters().restore();
           }
           command.execute(succeeded);
        });
   }

   public void withShiny(final String userAction, final Command command)
   {
      // create user prompt command
      CommandWith2Args<String, CommandWithArg<Boolean>> userPrompt =
            new CommandWith2Args<String, CommandWithArg<Boolean>>() {
         @Override
         public void execute(final String unmetDeps,
                             final CommandWithArg<Boolean> responseCommand)
         {
            globalDisplay_.showYesNoMessage(
              MessageDialog.QUESTION,
              constants_.installShinyCaption(),
              constants_.installShinyUserAction(userAction),
                  false, // include cancel
                  new Operation()
                  {
                     @Override
                     public void execute()
                     {
                        responseCommand.execute(true);
                     }
                  },
                  new Operation()
                  {
                     @Override
                     public void execute()
                     {
                        responseCommand.execute(false);
                     }
                  },
                  true);
          }
       };

       // perform dependency resolution
       withDependencies(
          constants_.installPkgsCaption(),
          userPrompt,
          getFeatureDependencies("shiny"),
          true,
          new CommandWithArg<Boolean>()
          {
            @Override
            public void execute(Boolean succeeded)
            {
               if (succeeded)
                  command.execute();
            }
          }
       );
   }

   public void withShinyAddins(final Command command)
   {
      withDependencies(
        constants_.withShinyAddinsCaption(),
        constants_.withShinyAddinsUserAction(),
        getFeatureDescription("shiny_addins"),
        getFeatureDependencies("shiny_addins"),
        false,
        new CommandWithArg<Boolean>()
        {
         @Override
         public void execute(Boolean succeeded)
         {
            if (succeeded)
               command.execute();
         }
        }
     );
   }
   
   public void withStyler(final Command command)
   {
      withDependencies(
            constants_.withStylerCaption(),
            constants_.withStylerUserAction(),
            getFeatureDescription("styler"),
            getFeatureDependencies("styler"),
            false,
            new CommandWithArg<Boolean>()
            {
               @Override
               public void execute(Boolean succeeded)
               {
                  if (succeeded)
                     command.execute();
               }
            });
   }

   @Override
   public void onInstallShiny(InstallShinyEvent event)
   {
      withShiny(event.getUserAction(),
                new Command() { public void execute() {}});
   }

   public void withReticulate(final String progressCaption,
                              final String userPrompt,
                              final Command command)
   {
      withDependencies(
            progressCaption,
            userPrompt,
            getFeatureDescription("reticulate"),
            getFeatureDependencies("reticulate"),
            true,
            new CommandWithArg<Boolean>()
            {
               @Override
               public void execute(Boolean succeeded)
               {
                  if (succeeded)
                     command.execute();
               }
            });
   }

   public void withStan(final String progressCaption,
                        final String userPrompt,
                        final Command command)
   {
      withDependencies(
            progressCaption,
            userPrompt,
            getFeatureDescription("stan"),
            getFeatureDependencies("stan"),
            true,
            (Boolean success) -> { if (success) command.execute(); });
   }

   public void withTinyTeX(final String progressCaption,
                           final String userPrompt,
                           final Command command)
   {
      withDependencies(
            progressCaption,
            userPrompt,
            getFeatureDescription("tinytex"),
            getFeatureDependencies("tinytex"),
            true,
            (Boolean success) -> { if (success) command.execute(); });
   }

   @Override
   public void onPackageStateChanged(PackageStateChangedEvent event)
   {
      // when the package state changes, clear the dependency cache -- this
      // is extremely conservative as it's unlikely most (or any) of the
      // packages have been invalidated, but it's safe to do so since it'll
      // just cause us to hit the server once more to verify
      satisfied_.clear();
   }

   public void withDataImportCSV(String userAction, final Command command)
   {
     withDependencies(
        constants_.withDataImportCSVCaption(),
        userAction,
        getFeatureDescription("import_csv"),
        getFeatureDependencies("import_csv"),
        false,
        new CommandWithArg<Boolean>()
        {
           @Override
           public void execute(Boolean succeeded)
           {
              if (succeeded)
                 command.execute();
           }
        }
     );
   }

   public void withDataImportSAV(String userAction, final Command command)
   {
     withDependencies(
        constants_.withDataImportSAV(),
        userAction,
        getFeatureDescription("import_sav"),
        getFeatureDependencies("import_sav"),
        false,
        new CommandWithArg<Boolean>()
        {
           @Override
           public void execute(Boolean succeeded)
           {
              if (succeeded)
                 command.execute();
           }
        }
     );
   }

   public void withDataImportXLS(String userAction, final Command command)
   {
     withDependencies(
        constants_.withDataImportXLS(),
        userAction,
        getFeatureDescription("import_xls"),
        getFeatureDependencies("import_xls"),
        false,
        new CommandWithArg<Boolean>()
        {
           @Override
           public void execute(Boolean succeeded)
           {
              if (succeeded)
                 command.execute();
           }
        }
     );
   }

   public void withDataImportXML(String userAction, final Command command)
   {
     withDependencies(
        constants_.withDataImportXML(),
        userAction,
        getFeatureDescription("import_xml"),
        getFeatureDependencies("import_xml"),
        false,
        new CommandWithArg<Boolean>()
        {
           @Override
           public void execute(Boolean succeeded)
           {
              if (succeeded)
                 command.execute();
           }
        }
     );
   }

   public void withDataImportJSON(String userAction, final Command command)
   {
     withDependencies(
        constants_.withDataImportJSON(),
        userAction,
        getFeatureDescription("import_json"),
        getFeatureDependencies("import_json"),
        false,
        new CommandWithArg<Boolean>()
        {
           @Override
           public void execute(Boolean succeeded)
           {
              if (succeeded)
                 command.execute();
           }
        }
     );
   }

   public void withDataImportJDBC(String userAction, final Command command)
   {
     withDependencies(
        constants_.withDataImportJDBC(),
        userAction,
        getFeatureDescription("import_jdbc"),
        getFeatureDependencies("import_jdbc"),
        false,
        new CommandWithArg<Boolean>()
        {
           @Override
           public void execute(Boolean succeeded)
           {
              if (succeeded)
                 command.execute();
           }
        }
     );
   }

   public void withDataImportODBC(String userAction, final Command command)
   {
     withDependencies(
        constants_.withDataImportODBC(),
        userAction,
        getFeatureDescription("import_odbc"),
        getFeatureDependencies("import_odbc"),
        false,
        new CommandWithArg<Boolean>()
        {
           @Override
           public void execute(Boolean succeeded)
           {
              if (succeeded)
                 command.execute();
           }
        }
     );
   }

   public void withDataImportMongo(String userAction, final Command command)
   {
     withDependencies(
        constants_.withDataImportMongoProgressCaption(),
        userAction,
        getFeatureDescription("import_mongo"),
        getFeatureDependencies("import_mongo"),
        false,
        new CommandWithArg<Boolean>()
        {
           @Override
           public void execute(Boolean succeeded)
           {
              if (succeeded)
                 command.execute();
           }
        }
     );
   }

   public void withProfvis(String userAction, final Command command)
   {
     withDependencies(
        constants_.withProfvis(),
        userAction,
        getFeatureDescription("profvis"),
        getFeatureDependencies("profvis"),
        true, // update profvis if needed
        new CommandWithArg<Boolean>()
        {
           @Override
           public void execute(Boolean succeeded)
           {
              if (succeeded)
                 command.execute();
           }
        }
     );
   }

   public void withConnectionPackage(String connectionName,
                                     String packageName,
                                     String packageVersion,
                                     final Operation operation)
   {
     withDependencies(
        constants_.withConnectionPackage(),
        connectionName,
        constants_.withConnectionPackageContext(),
        connectionPackageDependencies(packageName, packageVersion),
        false,
        new CommandWithArg<Boolean>()
        {
           @Override
           public void execute(Boolean succeeded)
           {
              if (succeeded)
                 operation.execute();
           }
        }
     );
   }

   public void withKeyring(final Command command)
   {
     withDependencies(
        constants_.withKeyring(),
        constants_.withKeyringUserAction(),
        getFeatureDescription("keyring"),
        getFeatureDependencies("keyring"),
        true, // update keyring if needed
        new CommandWithArg<Boolean>()
        {
           @Override
           public void execute(Boolean succeeded)
           {
              if (succeeded)
                 command.execute();
           }
        }
     );
   }

   public void withOdbc(final Command command, final String name)
   {
     withDependencies(
        constants_.withOdbc() + name,
        constants_.withOdbcUserAction() + name,
        getFeatureDescription("odbc"),
        getFeatureDependencies("odbc"),
        true, // update odbc if needed
        new CommandWithArg<Boolean>()
        {
           @Override
           public void execute(Boolean succeeded)
           {
              if (succeeded)
                 command.execute();
           }
        }
     );
   }

   public void withTestPackage(final Command command, boolean useTestThat)
   {
      List<Dependency> dependencies = new ArrayList<>();
      String message;
      if (useTestThat)
      {
         dependencies.addAll(getFeatureDependencies("testthat"));
         message = constants_.testthatMessage();
      }
      else
      {
         dependencies.addAll(getFeatureDependencies("shinytest2"));
         message = constants_.shinytest2Message();
      }

      withDependencies(
         constants_.preparingTestsProgressCaption(),
         message,
         constants_.testingToolsContext(),
         dependencies,
         true, // update package if needed
         new CommandWithArg<Boolean>()
         {
            @Override
            public void execute(Boolean succeeded)
            {
               if (succeeded) {
                  command.execute();
               }
            }
         }
      );
   }

   public void withDBI(String userAction, final Command command)
   {
      withDependencies(
        "DBI",
         userAction,
         getFeatureDescription("dbi"),
         getFeatureDependencies("dbi"),
         true,
         new CommandWithArg<Boolean>()
        {
           @Override
           public void execute(Boolean succeeded)
           {
              if (succeeded)
                 command.execute();
           }
        }
      );
   }

   public void withRSQLite(String userAction, final Command command)
   {
      withDependencies(
        "RSQLite",
         userAction,
         getFeatureDescription("rsqlite"),
         getFeatureDependencies("rsqlite"),
         true,
         new CommandWithArg<Boolean>()
        {
           @Override
           public void execute(Boolean succeeded)
           {
              if (succeeded)
                 command.execute();
           }
        }
      );
   }

   public void withTutorialDependencies(final Command command)
   {
      withDependencies(
            constants_.withTutorialDependencies(),
            constants_.withTutorialDependenciesUserAction(),
            getFeatureDescription("tutorial"),
            getFeatureDependencies("tutorial"),
            true,
            (Boolean succeeded) ->
            {
               if (succeeded)
                  command.execute();
            });
   }

   public void withRagg(String userAction, final CommandWithArg<Boolean> command)
   {
      withDependencies(
            "AGG",
            constants_.withRagg(),
            getFeatureDescription("ragg"),
            getFeatureDependencies("ragg"),
            true,
            command);
   }

   private ArrayList<Dependency> connectionPackageDependencies(
              String packageName,
              String packageVersion)
   {
    ArrayList<Dependency> deps = new ArrayList<>();
      deps.add(Dependency.cranPackage(packageName, packageVersion));

      return deps;
   }

   private void withDependencies(String progressCaption,
         final String userAction,
         final String context,
         final CommandWith2Args<String,CommandWithArg<Boolean>> userPrompt,
         List<Dependency> dependencies,
         final boolean silentEmbeddedUpdate,
         final CommandWithArg<Boolean> onComplete)
   {
      // add the request to the queue rather than processing it right away;
      // this frees us of the burden of trying to de-dupe requests for the
      // same packages which may occur simultaneously (since we also cache
      // results, all such duplicate requests will return simultaneously, fed
      // by a single RPC)
      requestQueue_.add(new DependencyRequest(progressCaption, userAction,
            context, userPrompt, dependencies, silentEmbeddedUpdate,
            new CommandWithArg<Boolean>()
            {
               @Override
               public void execute(Boolean arg)
               {
                  // complete the user action, if any
                  if (onComplete != null)
                     onComplete.execute(arg);

                  // process the next request in the queue
                  processingQueue_ = false;
                  processRequestQueue();
               }
            }));
      processRequestQueue();
   }

   private void processRequestQueue()
   {
      if (processingQueue_ == true || requestQueue_.isEmpty())
         return;
      processingQueue_ = true;
      processDependencyRequest(requestQueue_.pop());
   }
   
   private ProgressIndicator createProgressIndicator(DependencyRequest req)
   {
      return new GlobalProgressDelayer(
            globalDisplay_,
            250,
            req.progressCaption + "...").getIndicator();
   }

   private void processDependencyRequest(final DependencyRequest req)
   {
      // convert dependencies to JsArray, excluding satisfied dependencies
      final JsArray<Dependency> deps = JsArray.createArray().cast();
      for (Dependency dep: req.dependencies)
      {
         boolean satisfied = false;
         for (Dependency d: satisfied_)
         {
            if (dep.isEqualTo(d))
            {
               satisfied = true;
               break;
            }
         }
         if (!satisfied)
            deps.push(dep);
      }

      // if no unsatisfied dependencies were found, we're done already
      if (deps.length() == 0)
      {
         if (req.onComplete != null)
            req.onComplete.execute(true);
         return;
      }

      // create progress indicator
      final ProgressIndicator progress = createProgressIndicator(req);

      // query for unsatisfied dependencies
      server_.unsatisfiedDependencies(
            deps, req.silentEmbeddedUpdate,
            new ServerRequestCallback<JsArray<Dependency>>() {

         @Override
         public void onResponseReceived(
                              final JsArray<Dependency> unsatisfiedDeps)
         {
            progress.onCompleted();
            updateSatisfied(deps, unsatisfiedDeps);

            // if we've satisfied all dependencies then execute the command
            if (unsatisfiedDeps.length() == 0)
            {
               if (req.onComplete != null)
                  req.onComplete.execute(true);
               return;
            }

            // check to see if we can satisfy the version requirement for all
            // dependencies
            String unsatisfiedVersions = "";
            for (int i = 0; i < unsatisfiedDeps.length(); i++)
            {
               if (!unsatisfiedDeps.get(i).getVersionSatisfied())
               {
                  unsatisfiedVersions +=
                        "- " +
                        unsatisfiedDeps.get(i).getName() +
                        " " +
                        unsatisfiedDeps.get(i).getVersion();
                  
                  String version = unsatisfiedDeps.get(i).getAvailableVersion();
                  if (version.isEmpty())
                     unsatisfiedVersions += " " + constants_.unsatisfiedVersions() + "\n";
                  else
                     unsatisfiedVersions += " " + constants_.requiredVersion(version) + "\n";
               }
            }

            if (!unsatisfiedVersions.isEmpty())
            {
               // error if we can't satisfy requirements
               globalDisplay_.showErrorMessage(
                     StringUtil.isNullOrEmpty(req.userAction) ?
                     constants_.packageNotFoundUserAction() : req.userAction,
                     constants_.packageNotFoundMessage(unsatisfiedVersions));
               if (req.onComplete != null)
                  req.onComplete.execute(false);
            }
            else
            {
               // otherwise ask the user if they want to install the
               // unsatisifed dependencies
               final CommandWithArg<Boolean> installCommand =
                  new CommandWithArg<Boolean>() {
                  @Override
                  public void execute(Boolean confirmed)
                  {
                     // bail if user didn't confirm
                     if (!confirmed)
                     {
                        if (req.onComplete != null)
                           req.onComplete.execute(false);
                        return;
                     }

                     // the incoming JsArray from the server may not serialize
                     // as expected when this code is executed from a satellite
                     // (see RemoteServer.sendRequestViaMainWorkbench), so we
                     // clone it before passing to the dependency installer
                     JsArray<Dependency> newArray = JsArray.createArray().cast();
                     newArray.setLength(unsatisfiedDeps.length());
                     for (int i = 0; i < unsatisfiedDeps.length(); i++)
                     {
                        newArray.set(i, unsatisfiedDeps.get(i));
                     }
                     installDependencies(
                           req.context,
                           newArray,
                           req.silentEmbeddedUpdate,
                           req.onComplete);
                  }
               };

               if (req.userPrompt != null)
               {
                  req.userPrompt.execute(describeDepPkgs(unsatisfiedDeps),
                         new CommandWithArg<Boolean>()
                         {
                           @Override
                           public void execute(Boolean arg)
                           {
                              installCommand.execute(arg);
                           }
                         });
               }
               else
               {
                  confirmPackageInstallation(req.userAction,
                                             unsatisfiedDeps,
                                             installCommand);
               }
            }
         }

         @Override
         public void onError(ServerError error)
         {
            progress.onError(error.getUserMessage());
            if (req.onComplete != null)
               req.onComplete.execute(false);
         }
      });

   }

   private void installDependencies(final String context,
                                    final JsArray<Dependency> dependencies,
                                    final boolean silentEmbeddedUpdate,
                                    final CommandWithArg<Boolean> onComplete)
   {

      // Command to run when dependency installation is complete
      CommandWithArg<Boolean> onCompletion = satisfied ->
      {
         // Confirmed: dependencies are in place. Switch
         // away from the Jobs tab to put the user back in
         // context.
         if (satisfied)
         {
            events_.fireEvent(new ConsoleActivateEvent(false));
         }

         onComplete.execute(satisfied);
      };

      server_.installDependencies(
         context,
         dependencies,
         new ServerRequestCallback<String>() {
            @Override
            public void onResponseReceived(String jobId)
            {
               // Hold handler registration so we can clean it up when finished
               final Value<HandlerRegistration> reg = new Value<>(null);
               reg.setValue(events_.addHandler(JobUpdatedEvent.TYPE, event ->
               {
                  // Ensure the update is for this job
                  JobUpdate update = event.getData();
                  if (update.job.id != jobId)
                     return;

                  if (update.job.state == JobConstants.STATE_SUCCEEDED)
                  {
                     // If the job succeeded, check to be sure our dependencies
                     // are now satisfied
                     ifDependenciesSatisfied(dependencies,
                           silentEmbeddedUpdate,
                           onCompletion);

                     // Remove handler so we don't get notified on another job
                     // completion.
                     reg.getValue().removeHandler();
                  }
                  else if (update.job.state == JobConstants.STATE_FAILED ||
                           update.job.state == JobConstants.STATE_CANCELLED)
                  {
                     // If the job failed, or was cancelled, we're done
                     if (onComplete != null)
                        onComplete.execute(false);
                     reg.getValue().removeHandler();
                  }
               }));
            }

            @Override
            public void onError(ServerError error)
            {
               Debug.logError(error);
               globalDisplay_.showErrorMessage(
                     constants_.onErrorMessage(),
                     error.getUserMessage());
               if (onComplete != null)
                  onComplete.execute(false);
            }
         });
   }

   private void ifDependenciesSatisfied(JsArray<Dependency> dependencies,
                                boolean silentEmbeddedUpdate,
                                final CommandWithArg<Boolean> onComplete)
   {
      server_.unsatisfiedDependencies(
        dependencies, silentEmbeddedUpdate,
        new ServerRequestCallback<JsArray<Dependency>>() {

           @Override
           public void onResponseReceived(JsArray<Dependency> dependencies)
           {
              if (onComplete != null)
                 onComplete.execute(dependencies.length() == 0);
           }

           @Override
           public void onError(ServerError error)
           {
              Debug.logError(error);
              globalDisplay_.showErrorMessage(
                    constants_.availablePackageErrorMessage(),
                    error.getUserMessage());
              if (onComplete != null)
                 onComplete.execute(false);
           }
        });
   }

   private void confirmPackageInstallation(
      String userAction,
      final JsArray<Dependency> dependencies,
      final CommandWithArg<Boolean> onComplete)
   {
      String msg = null;
      if (dependencies.length() == 1)
      {
         msg = constants_.confirmPackageInstallation(dependencies.get(0).getName());
      }
      else
      {

         msg = constants_.updatedVersionMessage(describeDepPkgs(dependencies));
      }

      if (userAction != null)
      {
         globalDisplay_.showYesNoMessage(
            MessageDialog.QUESTION,
            constants_.installRequiredCaption(),
            userAction + " " + msg,
            false,
            new Operation() {
               @Override
               public void execute()
               {
                  onComplete.execute(true);
               }
            },
            new Operation() {
               @Override
               public void execute()
               {
                  onComplete.execute(false);
               }
            },
            true);
      }
      else
      {
         onComplete.execute(false);
      }
   }

   private String describeDepPkgs(JsArray<Dependency> dependencies)
   {
      ArrayList<String> deps = new ArrayList<>();
      for (int i = 0; i < dependencies.length(); i++)
         deps.add(dependencies.get(i).getName());
      return StringUtil.join(deps, ", ");
   }

   public void withUnsatisfiedDependencies(final Dependency dependency,
                                           final ServerRequestCallback<JsArray<Dependency>> requestCallback)
   {
      // determine if already satisfied
      for (Dependency d: satisfied_)
      {
         if (d.isEqualTo(dependency))
         {
            JsArray<Dependency> empty = JsArray.createArray().cast();
            requestCallback.onResponseReceived(empty);
            return;
         }
      }

      List<Dependency> dependencies = new ArrayList<>();
      dependencies.add(dependency);
      withUnsatisfiedDependencies(dependencies, requestCallback);
   }

   private void withUnsatisfiedDependencies(final List<Dependency> dependencies,
                                           final ServerRequestCallback<JsArray<Dependency>> requestCallback)
   {
      final JsArray<Dependency> jsDependencies =
            JsArray.createArray(dependencies.size()).cast();
      for (int i = 0; i < dependencies.size(); i++)
         jsDependencies.set(i, dependencies.get(i));

      server_.unsatisfiedDependencies(
            jsDependencies,
            false,
            new ServerRequestCallback<JsArray<Dependency>>()
            {
               @Override
               public void onResponseReceived(JsArray<Dependency> unsatisfied)
               {
                  updateSatisfied(jsDependencies, unsatisfied);
                  requestCallback.onResponseReceived(unsatisfied);
               }

               @Override
               public void onError(ServerError error)
               {
                  requestCallback.onError(error);
               }
            });
   }

   /**
    * Updates the cache of satisfied dependencies.
    *
    * @param all The dependencies that were requested
    * @param unsatisfied The dependencies that were not satisfied
    */
   private void updateSatisfied(JsArray<Dependency> all,
                                JsArray<Dependency> unsatisfied)
   {
      for (int i = 0; i < all.length(); i++)
      {
         boolean satisfied = true;
         for (int j = 0; j < unsatisfied.length(); j++)
         {
            if (unsatisfied.get(j).isEqualTo(all.get(i)))
            {
               satisfied = false;
               break;
            }
         }
         if (satisfied)
         {
            satisfied_.add(all.get(i));
         }
      }
   }

   /**
    * Retrieves a user-friendly name for an IDE feature that requires
    * dependencies
    *
    * @param feature The identifier of the IDE feature.
    * @return A string with a user-friendly name for the feature.
    */
   private String getFeatureDescription(String feature)
   {
      return session_.getSessionInfo().getPackageDependencies()
            .getFeatureDescription(feature);
   }

   /**
    * Retrieves a list of R package dependencies for an IDE feature.
    *
    * @param feature The identifier of the IDE feature.
    * @return An array of R packages the feature depends on.
    */
   private ArrayList<Dependency> getFeatureDependencies(String feature)
   {
      // The list of R package dependencies to return
      ArrayList<Dependency> dependencies = new ArrayList<>();

      // Read a list of package dependencies for this feature
      DependencyList list = session_.getSessionInfo().getPackageDependencies();
      JsArrayString packages = list.getFeatureDependencies(feature);

      // Find the metadata for each package (where we should install it from,
      // what version we need, etc.)
      for (int i = 0; i < packages.length(); i++)
      {
         Dependency dep = list.getPackage(packages.get(i));
         if (dep == null)
         {
            Debug.logWarning("No dependency record found for package '" +
                             packages.get(i) + "' (required by feature '" +
                             feature + "')");
            continue;
         }
         dep.setName(packages.get(i));
         dependencies.add(dep);
      }

      // Return the completed list
      return dependencies;
   }

   private boolean processingQueue_ = false;
   private final LinkedList<DependencyRequest> requestQueue_;
   private final GlobalDisplay globalDisplay_;
   private final DependencyServerOperations server_;
   private final ArrayList<Dependency> satisfied_;
   private final Session session_;
   private final Commands commands_;
   private final EventBus events_;
   private static final StudioClientCommonConstants constants_ = GWT.create(StudioClientCommonConstants.class);
}
