// Copyright (C) 2012 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.gerrit.server.plugins;

import com.google.common.base.CharMatcher;
import com.google.common.base.Joiner;
import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
import com.google.common.collect.ComparisonChain;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.LinkedHashMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.SetMultimap;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.extensions.events.LifecycleListener;
import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.systemstatus.ServerInformation;
import com.google.gerrit.server.PluginUser;
import com.google.gerrit.server.cache.PersistentCacheFactory;
import com.google.gerrit.server.config.CanonicalWebUrl;
import com.google.gerrit.server.config.ConfigUtil;
import com.google.gerrit.server.config.GerritRuntime;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.SitePaths;
import com.google.gerrit.server.plugins.ServerPluginProvider.PluginDescription;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.AbstractMap;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import org.eclipse.jgit.internal.storage.file.FileSnapshot;
import org.eclipse.jgit.lib.Config;

@Singleton
public class PluginLoader implements LifecycleListener {
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();

  public String getPluginName(Path srcPath) {
    return MoreObjects.firstNonNull(getGerritPluginName(srcPath), PluginUtil.nameOf(srcPath));
  }

  private final Path pluginsDir;
  private final Path dataDir;
  private final Path tempDir;
  private final PluginGuiceEnvironment env;
  private final ServerInformationImpl srvInfoImpl;
  private final PluginUser.Factory pluginUserFactory;
  private final ConcurrentMap<String, Plugin> running = Maps.newConcurrentMap();
  private final ConcurrentMap<String, Plugin> disabled = Maps.newConcurrentMap();
  private final Map<String, FileSnapshot> broken = Maps.newHashMap();
  private final Map<Plugin, CleanupHandle> cleanupHandles = Maps.newConcurrentMap();
  private final Queue<Plugin> toCleanup = new ArrayDeque<>();
  private final Provider<PluginCleanerTask> cleaner;
  private final PluginScannerThread scanner;
  private final Provider<String> urlProvider;
  private final PersistentCacheFactory persistentCacheFactory;
  private final boolean remoteAdmin;
  private final MandatoryPluginsCollection mandatoryPlugins;
  private final UniversalServerPluginProvider serverPluginFactory;
  private final GerritRuntime gerritRuntime;

  @Inject
  public PluginLoader(
      SitePaths sitePaths,
      PluginGuiceEnvironment pe,
      ServerInformationImpl sii,
      PluginUser.Factory puf,
      Provider<PluginCleanerTask> pct,
      @GerritServerConfig Config cfg,
      @CanonicalWebUrl Provider<String> provider,
      PersistentCacheFactory cacheFactory,
      UniversalServerPluginProvider pluginFactory,
      MandatoryPluginsCollection mpc,
      GerritRuntime gerritRuntime) {
    pluginsDir = sitePaths.plugins_dir;
    dataDir = sitePaths.data_dir;
    tempDir = sitePaths.tmp_dir;
    env = pe;
    srvInfoImpl = sii;
    pluginUserFactory = puf;
    cleaner = pct;
    urlProvider = provider;
    persistentCacheFactory = cacheFactory;
    serverPluginFactory = pluginFactory;

    remoteAdmin = cfg.getBoolean("plugins", null, "allowRemoteAdmin", false);
    mandatoryPlugins = mpc;
    this.gerritRuntime = gerritRuntime;

    long checkFrequency =
        ConfigUtil.getTimeUnit(
            cfg,
            "plugins",
            null,
            "checkFrequency",
            TimeUnit.MINUTES.toMillis(1),
            TimeUnit.MILLISECONDS);
    if (checkFrequency > 0) {
      scanner = new PluginScannerThread(this, checkFrequency);
    } else {
      scanner = null;
    }
  }

  public boolean isRemoteAdminEnabled() {
    return remoteAdmin;
  }

  public void checkRemoteAdminEnabled() throws MethodNotAllowedException {
    if (!remoteAdmin) {
      throw new MethodNotAllowedException("remote plugin administration is disabled");
    }
  }

  public Plugin get(String name) {
    Plugin p = running.get(name);
    if (p != null) {
      return p;
    }
    return disabled.get(name);
  }

  public Iterable<Plugin> getPlugins(boolean all) {
    if (!all) {
      return running.values();
    }
    List<Plugin> plugins = new ArrayList<>(running.values());
    plugins.addAll(disabled.values());
    return plugins;
  }

  public String installPluginFromStream(String originalName, InputStream in)
      throws IOException, PluginInstallException {
    checkRemoteInstall();

    String fileName = originalName;
    Path tmp = PluginUtil.asTemp(in, ".next_" + fileName + "_", ".tmp", pluginsDir);
    String name = MoreObjects.firstNonNull(getGerritPluginName(tmp), PluginUtil.nameOf(fileName));
    if (!originalName.equals(name)) {
      logger.atWarning().log(
          "Plugin provides its own name: <%s>, use it instead of the input name: <%s>",
          name, originalName);
    }

    String fileExtension = getExtension(fileName);
    Path dst = pluginsDir.resolve(name + fileExtension);
    synchronized (this) {
      Plugin active = running.get(name);
      if (active != null) {
        fileName = active.getSrcFile().getFileName().toString();
        logger.atInfo().log("Replacing plugin %s", active.getName());
        Path old = pluginsDir.resolve(".last_" + fileName);
        Files.deleteIfExists(old);
        Files.move(active.getSrcFile(), old);
      }

      Files.deleteIfExists(pluginsDir.resolve(fileName + ".disabled"));
      Files.move(tmp, dst);
      try {
        Plugin plugin = runPlugin(name, dst, active);
        if (active == null) {
          logger.atInfo().log("Installed plugin %s", plugin.getName());
        }
      } catch (PluginInstallException e) {
        Files.deleteIfExists(dst);
        throw e;
      }

      cleanInBackground();
    }

    return name;
  }

  private synchronized void unloadPlugin(Plugin plugin) {
    persistentCacheFactory.onStop(plugin.getName());
    String name = plugin.getName();
    logger.atInfo().log("Unloading plugin %s, version %s", name, plugin.getVersion());
    plugin.stop(env);
    env.onStopPlugin(plugin);
    running.remove(name);
    disabled.remove(name);
    toCleanup.add(plugin);
  }

  public void disablePlugins(Set<String> names) {
    if (!isRemoteAdminEnabled()) {
      logger.atWarning().log(
          "Remote plugin administration is disabled, ignoring disablePlugins(%s)", names);
      return;
    }

    synchronized (this) {
      for (String name : names) {
        Plugin active = running.get(name);
        if (active == null) {
          continue;
        }

        if (mandatoryPlugins.contains(name)) {
          logger.atWarning().log("Mandatory plugin %s cannot be disabled", name);
          continue;
        }

        logger.atInfo().log("Disabling plugin %s", active.getName());
        Path off =
            active.getSrcFile().resolveSibling(active.getSrcFile().getFileName() + ".disabled");
        try {
          Files.move(active.getSrcFile(), off);
        } catch (IOException e) {
          logger.atSevere().withCause(e).log("Failed to disable plugin");
          // In theory we could still unload the plugin even if the rename
          // failed. However, it would be reloaded on the next server startup,
          // which is probably not what the user expects.
          continue;
        }

        unloadPlugin(active);
        try {
          FileSnapshot snapshot = FileSnapshot.save(off.toFile());
          Plugin offPlugin = loadPlugin(name, off, snapshot);
          disabled.put(name, offPlugin);
        } catch (Throwable e) {
          // This shouldn't happen, as the plugin was loaded earlier.
          logger.atWarning().withCause(e.getCause()).log(
              "Cannot load disabled plugin %s", active.getName());
        }
      }
      cleanInBackground();
    }
  }

  public void enablePlugins(Set<String> names) throws PluginInstallException {
    if (!isRemoteAdminEnabled()) {
      logger.atWarning().log(
          "Remote plugin administration is disabled, ignoring enablePlugins(%s)", names);
      return;
    }

    synchronized (this) {
      for (String name : names) {
        Plugin off = disabled.get(name);
        if (off == null) {
          continue;
        }

        logger.atInfo().log("Enabling plugin %s", name);
        String n = off.getSrcFile().toFile().getName();
        if (n.endsWith(".disabled")) {
          n = n.substring(0, n.lastIndexOf('.'));
        }
        Path on = pluginsDir.resolve(n);
        try {
          Files.move(off.getSrcFile(), on);
        } catch (IOException e) {
          logger.atSevere().withCause(e).log("Failed to move plugin %s into place", name);
          continue;
        }
        disabled.remove(name);
        runPlugin(name, on, null);
      }
      cleanInBackground();
    }
  }

  private void removeStalePluginFiles() {
    DirectoryStream.Filter<Path> filter =
        entry -> entry.getFileName().toString().startsWith("plugin_");
    try (DirectoryStream<Path> files = Files.newDirectoryStream(tempDir, filter)) {
      for (Path file : files) {
        logger.atInfo().log("Removing stale plugin file: %s", file.toFile().getName());
        try {
          Files.delete(file);
        } catch (IOException e) {
          logger.atSevere().log(
              "Failed to remove stale plugin file %s: %s", file.toFile().getName(), e.getMessage());
        }
      }
    } catch (IOException e) {
      logger.atWarning().log("Unable to discover stale plugin files: %s", e.getMessage());
    }
  }

  @Override
  public synchronized void start() {
    removeStalePluginFiles();
    Path absolutePath = pluginsDir.toAbsolutePath();
    if (!Files.exists(absolutePath)) {
      logger.atInfo().log("%s does not exist; creating", absolutePath);
      try {
        Files.createDirectories(absolutePath);
      } catch (IOException e) {
        logger.atSevere().log("Failed to create %s: %s", absolutePath, e.getMessage());
      }
    }
    logger.atInfo().log("Loading plugins from %s", absolutePath);
    srvInfoImpl.state = ServerInformation.State.STARTUP;
    rescan();
    srvInfoImpl.state = ServerInformation.State.RUNNING;
    if (scanner != null) {
      scanner.start();
    }
  }

  @Override
  public void stop() {
    if (scanner != null) {
      scanner.end();
    }
    srvInfoImpl.state = ServerInformation.State.SHUTDOWN;
    synchronized (this) {
      for (Plugin p : running.values()) {
        unloadPlugin(p);
      }
      running.clear();
      disabled.clear();
      broken.clear();
      if (!toCleanup.isEmpty()) {
        System.gc();
        processPendingCleanups();
      }
    }
  }

  public void reload(List<String> names) throws InvalidPluginException, PluginInstallException {
    synchronized (this) {
      List<Plugin> reload = Lists.newArrayListWithCapacity(names.size());
      List<String> bad = Lists.newArrayListWithExpectedSize(4);
      for (String name : names) {
        Plugin active = running.get(name);
        if (active != null) {
          reload.add(active);
        } else {
          bad.add(name);
        }
      }
      if (!bad.isEmpty()) {
        throw new InvalidPluginException(
            String.format("Plugin(s) \"%s\" not running", Joiner.on("\", \"").join(bad)));
      }

      for (Plugin active : reload) {
        String name = active.getName();
        try {
          logger.atInfo().log("Reloading plugin %s", name);
          Plugin newPlugin = runPlugin(name, active.getSrcFile(), active);
          logger.atInfo().log(
              "Reloaded plugin %s, version %s", newPlugin.getName(), newPlugin.getVersion());
        } catch (PluginInstallException e) {
          logger.atWarning().withCause(e.getCause()).log("Cannot reload plugin %s", name);
          throw e;
        }
      }

      cleanInBackground();
    }
  }

  public synchronized void rescan() {
    Set<String> loadedPlugins = new HashSet<>();
    SetMultimap<String, Path> pluginsFiles = prunePlugins(pluginsDir);

    if (!pluginsFiles.isEmpty()) {
      syncDisabledPlugins(pluginsFiles);

      Map<String, Path> activePlugins = filterDisabled(pluginsFiles);
      for (Map.Entry<String, Path> entry : jarsFirstSortedPluginsSet(activePlugins)) {
        String name = entry.getKey();
        Path path = entry.getValue();
        String fileName = path.getFileName().toString();
        if (!isUiPlugin(fileName) && !serverPluginFactory.handles(path)) {
          logger.atWarning().log(
              "No Plugin provider was found that handles this file format: %s", fileName);
          continue;
        }

        FileSnapshot brokenTime = broken.get(name);
        if (brokenTime != null && !brokenTime.isModified(path.toFile())) {
          continue;
        }

        Plugin active = running.get(name);
        if (active != null && !active.isModified(path)) {
          loadedPlugins.add(name);
          continue;
        }

        if (active != null) {
          logger.atInfo().log("Reloading plugin %s", active.getName());
        }

        try {
          Plugin loadedPlugin = runPlugin(name, path, active);
          if (!loadedPlugin.isDisabled()) {
            loadedPlugins.add(name);
            logger.atInfo().log(
                "%s plugin %s, version %s",
                active == null ? "Loaded" : "Reloaded",
                loadedPlugin.getName(),
                loadedPlugin.getVersion());
          }
        } catch (PluginInstallException e) {
          logger.atWarning().withCause(e.getCause()).log("Cannot load plugin %s", name);
        }
      }
    }

    Set<String> missingMandatory = Sets.difference(mandatoryPlugins.asSet(), loadedPlugins);
    if (!missingMandatory.isEmpty()) {
      throw new MissingMandatoryPluginsException(missingMandatory);
    }

    cleanInBackground();
  }

  private void addAllEntries(Map<String, Path> from, TreeSet<Map.Entry<String, Path>> to) {
    Iterator<Map.Entry<String, Path>> it = from.entrySet().iterator();
    while (it.hasNext()) {
      Map.Entry<String, Path> entry = it.next();
      to.add(new AbstractMap.SimpleImmutableEntry<>(entry.getKey(), entry.getValue()));
    }
  }

  private TreeSet<Map.Entry<String, Path>> jarsFirstSortedPluginsSet(
      Map<String, Path> activePlugins) {
    TreeSet<Map.Entry<String, Path>> sortedPlugins =
        Sets.newTreeSet(
            new Comparator<Map.Entry<String, Path>>() {
              @Override
              public int compare(Map.Entry<String, Path> e1, Map.Entry<String, Path> e2) {
                Path n1 = e1.getValue().getFileName();
                Path n2 = e2.getValue().getFileName();
                return ComparisonChain.start()
                    .compareTrueFirst(isJar(n1), isJar(n2))
                    .compare(n1, n2)
                    .result();
              }

              private boolean isJar(Path n1) {
                return n1.toString().endsWith(".jar");
              }
            });

    addAllEntries(activePlugins, sortedPlugins);
    return sortedPlugins;
  }

  private void syncDisabledPlugins(SetMultimap<String, Path> jars) {
    stopRemovedPlugins(jars);
    dropRemovedDisabledPlugins(jars);
  }

  private Plugin runPlugin(String name, Path plugin, Plugin oldPlugin)
      throws PluginInstallException {
    FileSnapshot snapshot = FileSnapshot.save(plugin.toFile());
    try {
      boolean restartRequired = oldPlugin != null && !oldPlugin.canReload();
      if (restartRequired && mandatoryPlugins.contains(name)) {
        logger.atWarning().log("Restarting mandatory plugin %s not allowed", name);
        return oldPlugin;
      }

      Plugin newPlugin = loadPlugin(name, plugin, snapshot);
      if (newPlugin.getCleanupHandle() != null) {
        cleanupHandles.put(newPlugin, newPlugin.getCleanupHandle());
      }
      /*
       * Pluggable plugin provider may have assigned a plugin name that could be
       * actually different from the initial one assigned during scan. It is
       * safer then to reassign it.
       */
      name = newPlugin.getName();
      boolean reload = oldPlugin != null && oldPlugin.canReload() && newPlugin.canReload();
      if (!reload && oldPlugin != null) {
        unloadPlugin(oldPlugin);
      }
      if (!newPlugin.isDisabled()) {
        try {
          newPlugin.start(env);
        } catch (Throwable e) {
          newPlugin.stop(env);
          throw e;
        }
      }
      if (reload) {
        env.onReloadPlugin(oldPlugin, newPlugin);
        unloadPlugin(oldPlugin);
      } else if (!newPlugin.isDisabled()) {
        env.onStartPlugin(newPlugin);
      }
      if (!newPlugin.isDisabled()) {
        running.put(name, newPlugin);
      } else {
        disabled.put(name, newPlugin);
      }
      broken.remove(name);
      return newPlugin;
    } catch (Throwable err) {
      broken.put(name, snapshot);
      throw new PluginInstallException(err);
    }
  }

  private void stopRemovedPlugins(SetMultimap<String, Path> jars) {
    Set<String> unload = Sets.newHashSet(running.keySet());
    for (Map.Entry<String, Collection<Path>> entry : jars.asMap().entrySet()) {
      for (Path path : entry.getValue()) {
        if (!path.getFileName().toString().endsWith(".disabled")) {
          unload.remove(entry.getKey());
        }
      }
    }
    for (String name : unload) {
      unloadPlugin(running.get(name));
    }
  }

  private void dropRemovedDisabledPlugins(SetMultimap<String, Path> jars) {
    Set<String> unload = Sets.newHashSet(disabled.keySet());
    for (Map.Entry<String, Collection<Path>> entry : jars.asMap().entrySet()) {
      for (Path path : entry.getValue()) {
        if (path.getFileName().toString().endsWith(".disabled")) {
          unload.remove(entry.getKey());
        }
      }
    }
    for (String name : unload) {
      disabled.remove(name);
    }
  }

  synchronized int processPendingCleanups() {
    Iterator<Plugin> iterator = toCleanup.iterator();
    while (iterator.hasNext()) {
      Plugin plugin = iterator.next();
      iterator.remove();

      CleanupHandle cleanupHandle = cleanupHandles.remove(plugin);
      if (cleanupHandle != null) {
        cleanupHandle.cleanup();
      }
    }
    return toCleanup.size();
  }

  private void cleanInBackground() {
    int cnt = toCleanup.size();
    if (0 < cnt) {
      cleaner.get().clean(cnt);
    }
  }

  private String getExtension(String name) {
    int ext = name.lastIndexOf('.');
    return 0 < ext ? name.substring(ext) : "";
  }

  private Plugin loadPlugin(String name, Path srcPlugin, FileSnapshot snapshot)
      throws InvalidPluginException {
    String pluginName = srcPlugin.getFileName().toString();
    if (isUiPlugin(pluginName)) {
      return loadJsPlugin(name, srcPlugin, snapshot);
    } else if (serverPluginFactory.handles(srcPlugin)) {
      return loadServerPlugin(srcPlugin, snapshot);
    } else {
      throw new InvalidPluginException(
          String.format("Unsupported plugin type: %s", srcPlugin.getFileName()));
    }
  }

  private Path getPluginDataDir(String name) {
    return dataDir.resolve(name);
  }

  private String getPluginCanonicalWebUrl(String name) {
    String canonicalWebUrl = urlProvider.get();
    if (Strings.isNullOrEmpty(canonicalWebUrl)) {
      return "/plugins/" + name;
    }

    String url =
        String.format(
            "%s/plugins/%s/", CharMatcher.is('/').trimTrailingFrom(canonicalWebUrl), name);
    return url;
  }

  private Plugin loadJsPlugin(String name, Path srcJar, FileSnapshot snapshot) {
    return new JsPlugin(name, srcJar, pluginUserFactory.create(name), snapshot);
  }

  private ServerPlugin loadServerPlugin(Path scriptFile, FileSnapshot snapshot)
      throws InvalidPluginException {
    String name = serverPluginFactory.getPluginName(scriptFile);
    return serverPluginFactory.get(
        scriptFile,
        snapshot,
        new PluginDescription(
            pluginUserFactory.create(name),
            getPluginCanonicalWebUrl(name),
            getPluginDataDir(name),
            gerritRuntime));
  }

  // Only one active plugin per plugin name can exist for each plugin name.
  // Filter out disabled plugins and transform the multimap to a map
  private Map<String, Path> filterDisabled(SetMultimap<String, Path> pluginPaths) {
    Map<String, Path> activePlugins = Maps.newHashMapWithExpectedSize(pluginPaths.keys().size());
    for (String name : pluginPaths.keys()) {
      for (Path pluginPath : pluginPaths.asMap().get(name)) {
        if (!pluginPath.getFileName().toString().endsWith(".disabled")) {
          assert !activePlugins.containsKey(name);
          activePlugins.put(name, pluginPath);
        }
      }
    }
    return activePlugins;
  }

  // Scan the $site_path/plugins directory and fetch all files and directories.
  // The Key in returned multimap is the plugin name initially assigned from its filename.
  // Values are the files. Plugins can optionally provide their name in MANIFEST file.
  // If multiple plugin files provide the same plugin name, then only
  // the first plugin remains active and all other plugins with the same
  // name are disabled.
  //
  // NOTE: Bear in mind that the plugin name can be reassigned after load by the
  //       Server plugin provider.
  public SetMultimap<String, Path> prunePlugins(Path pluginsDir) {
    List<Path> pluginPaths = scanPathsInPluginsDirectory(pluginsDir);
    SetMultimap<String, Path> map;
    map = asMultimap(pluginPaths);
    for (String plugin : map.keySet()) {
      Collection<Path> files = map.asMap().get(plugin);
      if (files.size() == 1) {
        continue;
      }
      // retrieve enabled plugins
      Iterable<Path> enabled = filterDisabledPlugins(files);
      // If we have only one (the winner) plugin, nothing to do
      if (!Iterables.skip(enabled, 1).iterator().hasNext()) {
        continue;
      }
      Path winner = Iterables.getFirst(enabled, null);
      assert winner != null;
      // Disable all loser plugins by renaming their file names to
      // "file.disabled" and replace the disabled files in the multimap.
      Collection<Path> elementsToRemove = new ArrayList<>();
      Collection<Path> elementsToAdd = new ArrayList<>();
      for (Path loser : Iterables.skip(enabled, 1)) {
        logger.atWarning().log(
            "Plugin <%s> was disabled, because"
                + " another plugin <%s>"
                + " with the same name <%s> already exists",
            loser, winner, plugin);
        Path disabledPlugin = Paths.get(loser + ".disabled");
        elementsToAdd.add(disabledPlugin);
        elementsToRemove.add(loser);
        try {
          Files.move(loser, disabledPlugin);
        } catch (IOException e) {
          logger.atWarning().withCause(e).log("Failed to fully disable plugin %s", loser);
        }
      }
      Iterables.removeAll(files, elementsToRemove);
      Iterables.addAll(files, elementsToAdd);
    }
    return map;
  }

  private List<Path> scanPathsInPluginsDirectory(Path pluginsDir) {
    try {
      return PluginUtil.listPlugins(pluginsDir);
    } catch (IOException e) {
      logger.atSevere().withCause(e).log("Cannot list %s", pluginsDir.toAbsolutePath());
      return ImmutableList.of();
    }
  }

  private Iterable<Path> filterDisabledPlugins(Collection<Path> paths) {
    return Iterables.filter(paths, p -> !p.getFileName().toString().endsWith(".disabled"));
  }

  public String getGerritPluginName(Path srcPath) {
    String fileName = srcPath.getFileName().toString();
    if (isUiPlugin(fileName)) {
      return fileName.substring(0, fileName.lastIndexOf('.'));
    }
    if (serverPluginFactory.handles(srcPath)) {
      return serverPluginFactory.getPluginName(srcPath);
    }
    return null;
  }

  private SetMultimap<String, Path> asMultimap(List<Path> plugins) {
    SetMultimap<String, Path> map = LinkedHashMultimap.create();
    for (Path srcPath : plugins) {
      map.put(getPluginName(srcPath), srcPath);
    }
    return map;
  }

  private boolean isUiPlugin(String name) {
    return isPlugin(name, "js") || isPlugin(name, "html");
  }

  private boolean isPlugin(String fileName, String ext) {
    String fullExt = "." + ext;
    return fileName.endsWith(fullExt) || fileName.endsWith(fullExt + ".disabled");
  }

  private void checkRemoteInstall() throws PluginInstallException {
    if (!isRemoteAdminEnabled()) {
      throw new PluginInstallException("remote installation is disabled");
    }
  }
}
