// Copyright (C) 2016 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;

import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.lifecycle.LifecycleManager;
import com.google.gerrit.server.plugins.DelegatingClassLoader;
import com.google.gerrit.util.cli.CmdLineParser;
import com.google.inject.Injector;
import com.google.inject.Module;
import com.google.inject.Provider;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.WeakHashMap;

/** Helper class to define and parse options from plugins on ssh and RestAPI commands. */
public class DynamicOptions implements AutoCloseable {
  /**
   * To provide additional options, bind a DynamicBean. For example:
   *
   * <pre>
   *   bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
   *       .annotatedWith(Exports.named(com.google.gerrit.sshd.commands.Query.class))
   *       .to(MyOptions.class);
   * </pre>
   *
   * To define the additional options, implement this interface. For example:
   *
   * <pre>
   *   public class MyOptions implements DynamicOptions.DynamicBean {
   *     {@literal @}Option(name = "--verbose", aliases = {"-v"}
   *             usage = "Make the operation more talkative")
   *     public boolean verbose;
   *   }
   * </pre>
   *
   * <p>The option will be prefixed by the plugin name. In the example above, if the plugin name was
   * my-plugin, then the --verbose option as used by the caller would be --my-plugin--verbose.
   *
   * <p>Additional options can be annotated with @RequiresOption which will cause them to be ignored
   * unless the required option is present. For example:
   *
   * <pre>
   *   {@literal @}RequiresOptions("--help")
   *   {@literal @}Option(name = "--help-as-json",
   *           usage = "display help text in json format")
   *   public boolean displayHelpAsJson;
   * </pre>
   */
  public interface DynamicBean {}

  /**
   * To provide additional options to a command in another classloader, bind a ClassNameProvider
   * which provides the name of your DynamicBean in the other classLoader.
   *
   * <p>Do this by binding to just the name of the command you are going to bind to so that your
   * classLoader does not load the command's class which likely is not in your classpath. To ensure
   * that the command's class is not in your classpath, you can exclude it during your build.
   *
   * <p>For example:
   *
   * <pre>
   *   bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
   *       .annotatedWith(Exports.named( "com.google.gerrit.plugins.otherplugin.command"))
   *       .to(MyOptionsClassNameProvider.class);
   *
   *   static class MyOptionsClassNameProvider implements DynamicOptions.ClassNameProvider {
   *     {@literal @}Override
   *     public String getClassName() {
   *       return "com.googlesource.gerrit.plugins.myplugin.CommandOptions";
   *     }
   *   }
   * </pre>
   */
  public interface ClassNameProvider extends DynamicBean {
    String getClassName();
  }

  /**
   * To provide additional Guice bindings for options to a command in another classloader, bind a
   * ModulesClassNamesProvider which provides the name of your Modules needed for your DynamicBean
   * in the other classLoader.
   *
   * <p>Do this by binding to the name of the command you are going to bind to and providing an
   * Iterable of Module names to instantiate and add to the Injector used to instantiate the
   * DynamicBean in the other classLoader. This interface supports running LifecycleListeners which
   * are defined by the Modules being provided. The duration of the lifecycle starts when a ssh or
   * http request starts and ends when the request completes. For example:
   *
   * <pre>
   *   bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
   *       .annotatedWith(Exports.named(
   *           "com.google.gerrit.plugins.otherplugin.command"))
   *       .to(MyOptionsModulesClassNamesProvider.class);
   *
   *   static class MyOptionsModulesClassNamesProvider implements DynamicOptions.ModulesClassNamesProvider {
   *     {@literal @}Override
   *     public String getClassName() {
   *       return "com.googlesource.gerrit.plugins.myplugin.CommandOptions";
   *     }
   *     {@literal @}Override
   *     public Iterable<String> getModulesClassNames()() {
   *       return "com.googlesource.gerrit.plugins.myplugin.MyOptionsModule";
   *     }
   *   }
   * </pre>
   */
  public interface ModulesClassNamesProvider extends ClassNameProvider {
    Iterable<String> getModulesClassNames();
  }

  /**
   * Implement this if your DynamicBean needs an opportunity to act on the Bean directly before or
   * after argument parsing.
   */
  public interface BeanParseListener extends DynamicBean {
    void onBeanParseStart(String plugin, Object bean);

    void onBeanParseEnd(String plugin, Object bean);
  }

  /**
   * The entity which provided additional options may need a way to receive a reference to the
   * DynamicBean it provided. To do so, the existing class should implement BeanReceiver (a setter)
   * and then provide some way for the plugin to request its DynamicBean (a getter.) For example:
   *
   * <pre>
   *   public class Query extends SshCommand implements DynamicOptions.BeanReceiver {
   *       public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
   *         dynamicBeans.put(plugin, dynamicBean);
   *       }
   *
   *       public DynamicOptions.DynamicBean getDynamicBean(String plugin) {
   *         return dynamicBeans.get(plugin);
   *       }
   *   ...
   *   }
   * }
   * </pre>
   */
  public interface BeanReceiver {
    void setDynamicBean(String plugin, DynamicBean dynamicBean);

    /**
     * Returns the class that should be used for looking up exported DynamicBean bindings from
     * plugins. Override when a particular REST/SSH endpoint should respect DynamicBeans bound on a
     * different endpoint. For example, {@code GetDetail} is just a synonym for a variant of {@code
     * GetChange}, and it should respect any DynamicBeans on GetChange. GetChange}. So it should
     * return {@code GetChange.class} from this method.
     */
    default Class<? extends BeanReceiver> getExportedBeanReceiver() {
      return getClass();
    }
  }

  public interface BeanProvider {
    DynamicBean getDynamicBean(String plugin);
  }

  /**
   * MergedClassloaders allow us to load classes from both plugin classloaders. Store the merged
   * classloaders in a Map to avoid creating a new classloader for each invocation. Use a
   * WeakHashMap to avoid leaking these MergedClassLoaders once either plugin is unloaded. Since the
   * WeakHashMap only takes care of ensuring the Keys can get garbage collected, use WeakReferences
   * to store the MergedClassloaders in the WeakHashMap.
   *
   * <p>Outter keys are the bean plugin's classloaders (the plugin being extended)
   *
   * <p>Inner keys are the dynamicBeans plugin's classloaders (the extending plugin)
   *
   * <p>The value is the MergedClassLoader representing the merging of the outter and inner key
   * classloaders.
   */
  protected static Map<ClassLoader, Map<ClassLoader, WeakReference<ClassLoader>>> mergedClByCls =
      Collections.synchronizedMap(new WeakHashMap<>());

  protected Object bean;
  protected Map<String, DynamicBean> beansByPlugin;
  protected Injector injector;
  protected DynamicMap<DynamicBean> dynamicBeans;
  protected LifecycleManager lifecycleManager;

  /**
   * Internal: For Gerrit to include options from DynamicBeans, setup a DynamicMap and instantiate
   * this class so the following methods can be called if desired:
   *
   * <pre>
   *    DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans);
   *    pluginOptions.setBean(bean);
   *    pluginOptions.startLifecycleListeners();
   *    pluginOptions.parseDynamicBeans(clp);
   *    pluginOptions.setDynamicBeans();
   *    pluginOptions.onBeanParseStart();
   *
   *    // parse arguments here:  clp.parseArgument(argv);
   *
   *    pluginOptions.onBeanParseEnd();
   * </pre>
   */
  public DynamicOptions(Injector injector, DynamicMap<DynamicBean> dynamicBeans) {
    this.injector = injector;
    this.dynamicBeans = dynamicBeans;
    lifecycleManager = new LifecycleManager();
    beansByPlugin = new HashMap<>();
  }

  public void setBean(Object bean) {
    this.bean = bean;
    Class<?> beanClass =
        (bean instanceof BeanReceiver)
            ? ((BeanReceiver) bean).getExportedBeanReceiver()
            : getClass();
    for (String plugin : dynamicBeans.plugins()) {
      Provider<DynamicBean> provider =
          dynamicBeans.byPlugin(plugin).get(beanClass.getCanonicalName());
      if (provider != null) {
        beansByPlugin.put(plugin, getDynamicBean(bean, provider.get()));
      }
    }
  }

  @SuppressWarnings("unchecked")
  public DynamicBean getDynamicBean(Object bean, DynamicBean dynamicBean) {
    ClassLoader coreCl = getClass().getClassLoader();
    ClassLoader beanCl = bean.getClass().getClassLoader();

    ClassLoader loader = beanCl;
    if (beanCl != coreCl) { // bean from a plugin?
      ClassLoader dynamicBeanCl = dynamicBean.getClass().getClassLoader();
      if (beanCl != dynamicBeanCl) { // in a different plugin?
        loader = getMergedClassLoader(beanCl, dynamicBeanCl);
      }
    }

    String className = null;
    if (dynamicBean instanceof ClassNameProvider) {
      className = ((ClassNameProvider) dynamicBean).getClassName();
    } else if (loader != beanCl) { // in a different plugin?
      className = dynamicBean.getClass().getCanonicalName();
    }

    if (className != null) {
      try {
        List<Module> modules = new ArrayList<>();
        Injector modulesInjector = injector;
        if (dynamicBean instanceof ModulesClassNamesProvider) {
          modulesInjector = injector.createChildInjector();
          for (String moduleName :
              ((ModulesClassNamesProvider) dynamicBean).getModulesClassNames()) {
            Class<Module> mClass = (Class<Module>) loader.loadClass(moduleName);
            modules.add(modulesInjector.getInstance(mClass));
          }
        }
        Injector childModulesInjector = modulesInjector.createChildInjector(modules);
        lifecycleManager.add(childModulesInjector);
        return childModulesInjector.getInstance(
            (Class<DynamicOptions.DynamicBean>) loader.loadClass(className));
      } catch (ClassNotFoundException e) {
        throw new RuntimeException(e);
      }
    }

    return dynamicBean;
  }

  protected ClassLoader getMergedClassLoader(ClassLoader beanCl, ClassLoader dynamicBeanCl) {
    Map<ClassLoader, WeakReference<ClassLoader>> mergedClByCl = mergedClByCls.get(beanCl);
    if (mergedClByCl == null) {
      mergedClByCl = Collections.synchronizedMap(new WeakHashMap<>());
      mergedClByCls.put(beanCl, mergedClByCl);
    }
    WeakReference<ClassLoader> mergedClRef = mergedClByCl.get(dynamicBeanCl);
    ClassLoader mergedCl = null;
    if (mergedClRef != null) {
      mergedCl = mergedClRef.get();
    }
    if (mergedCl == null) {
      mergedCl = new DelegatingClassLoader(beanCl, dynamicBeanCl);
      mergedClByCl.put(dynamicBeanCl, new WeakReference<>(mergedCl));
    }
    return mergedCl;
  }

  public void parseDynamicBeans(CmdLineParser clp) {
    for (Map.Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
      clp.parseWithPrefix("--" + e.getKey(), e.getValue());
    }
    clp.drainOptionQueue();
  }

  public void setDynamicBeans() {
    if (bean instanceof BeanReceiver) {
      BeanReceiver receiver = (BeanReceiver) bean;
      for (Map.Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
        receiver.setDynamicBean(e.getKey(), e.getValue());
      }
    }
  }

  public void startLifecycleListeners() {
    lifecycleManager.start();
  }

  public void stopLifecycleListeners() {
    lifecycleManager.stop();
  }

  public void onBeanParseStart() {
    for (Map.Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
      DynamicBean instance = e.getValue();
      if (instance instanceof BeanParseListener) {
        BeanParseListener listener = (BeanParseListener) instance;
        listener.onBeanParseStart(e.getKey(), bean);
      }
    }
  }

  public void onBeanParseEnd() {
    for (Map.Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
      DynamicBean instance = e.getValue();
      if (instance instanceof BeanParseListener) {
        BeanParseListener listener = (BeanParseListener) instance;
        listener.onBeanParseEnd(e.getKey(), bean);
      }
    }
  }

  @Override
  public void close() {
    stopLifecycleListeners();
  }
}
