/*
 * CommandBundleGenerator.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.core.rebind.command;

import com.google.gwt.core.ext.Generator;
import com.google.gwt.core.ext.GeneratorContext;
import com.google.gwt.core.ext.TreeLogger;
import com.google.gwt.core.ext.UnableToCompleteException;
import com.google.gwt.core.ext.typeinfo.JClassType;
import com.google.gwt.core.ext.typeinfo.JMethod;
import com.google.gwt.dev.resource.Resource;
import com.google.gwt.user.rebind.ClassSourceFileComposerFactory;
import com.google.gwt.user.rebind.SourceWriter;

import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;

import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathFactory;

import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

/**
 * This generator runs at compile time (like all GWT Generators) and creates
 * implementations for any subinterfaces of CommandBundle that are found in
 * client code.
 */
public class CommandBundleGenerator extends Generator
{
   @Override
   public String generate(TreeLogger logger,
                          GeneratorContext context,
                          String typeName) throws UnableToCompleteException
   {
      try
      {
         return new CommandBundleGeneratorHelper(logger,
                                                 context,
                                                 typeName).generate();
      }
      catch (UnableToCompleteException e)
      {
         throw e;
      }
      catch (Exception e)
      {
         logger.log(TreeLogger.Type.ERROR, "Barf", e);
         throw new UnableToCompleteException();
      }
   }
}

/**
 * The actual logic for type generation is moved into this separate class
 * so we can access a lot of the common info as fields instead of passing
 * them around.
 */
class CommandBundleGeneratorHelper
{
   CommandBundleGeneratorHelper(TreeLogger logger,
                                GeneratorContext context,
                                String typeName) throws Exception
   {
      logger_ = logger;
      context_ = context;
      bundleType_ = context_.getTypeOracle().getType(typeName);
      commandMethods_ = getMethods(true, false, false);
      menuMethods_ = getMethods(false, true, false);
      shortcutsMethods_ = getMethods(false, false, true);
      commandProps_ = getCommandProperties();
      packageName_ = bundleType_.getPackage().getName();
   }

   /**
    * Generates the impl class and returns its name.
    */
   public String generate() throws Exception
   {
      ImageResourceInfo images = generateImageBundle();

      simpleName_ = bundleType_.getName().replace('.', '_') + "__Impl";

      PrintWriter printWriter = context_.tryCreate(
            logger_, packageName_, simpleName_);
      if (printWriter != null)
      {
         // I don't fully understand why images is sometimes null but we better
         // not get into a situation where we are generating this type but don't
         // know what images we can use. Empirically it seems like images is
         // always null only when printWriter is also null.
         assert images != null;

         ClassSourceFileComposerFactory factory =
               new ClassSourceFileComposerFactory(packageName_, simpleName_);
         factory.setSuperclass(bundleType_.getName());
         factory.addImport("org.rstudio.core.client.command.AppCommand");
         factory.addImport("org.rstudio.core.client.command.MenuCallback");
         factory.addImport("org.rstudio.core.client.command.ShortcutManager");
         factory.addImport("org.rstudio.core.client.resources.ImageResource2x");
         factory.addImport("com.google.gwt.core.client.GWT");
         factory.addImport("org.rstudio.studio.client.workbench.commands.CmdConstants");
         SourceWriter writer = factory.createSourceWriter(context_, printWriter);

         emitConstructor(writer, images);
         emitCommandFields(writer);
         emitMenus(writer);
         emitShortcuts(writer);
         emitCommandAccessors(writer);
         emitI18n(writer);

         // Close the class and commit it
         writer.outdent();
         writer.println("}");
         context_.commit(logger_, printWriter);
      }
      return packageName_ + "." + simpleName_;
   }

   private void emitConstructor(SourceWriter writer, ImageResourceInfo images)
   {
      writer.println("public " + simpleName_ + "() {");

      // Implement the methods for the commands
      for (JMethod method : commandMethods_)
         emitCommandInitializers(writer, method, images);

      writer.println();
      writer.indentln("__registerShortcuts();");

      writer.println("}");
   }

   private void emitCommandFields(SourceWriter writer)
   {
      // Declare the fields for the commands
      for (JMethod method : commandMethods_)
      {
         String name = method.getName();
         writer.println("private AppCommand " + name + "_;");
      }
   }

   private void emitI18n(SourceWriter writer)
   {
      writer.println("private CmdConstants " + i18n_constants_name + " = GWT.create(CmdConstants.class);");
   }

   private void emitMenus(SourceWriter writer) throws UnableToCompleteException
   {
      for (JMethod method : menuMethods_)
      {
         String name = method.getName();
         NodeList nodes = getConfigDoc("/commands/menu[@id='" + name + "']");
         if (nodes.getLength() == 0)
         {
            logger_.log(TreeLogger.Type.ERROR,
                        "Unable to find config info for menu " + name);
            throw new UnableToCompleteException();
         }
         else if (nodes.getLength() > 1)
         {
            logger_.log(TreeLogger.Type.ERROR,
                        "Duplicate menu entries for menu " + name);
         }

         String menuClass = new MenuEmitter(logger_,
                                            context_,
                                            bundleType_,
                                            commandProps_,
                                            (Element) nodes.item(0)).generate();

         writer.println("public void " + name + "(MenuCallback callback) {");
         writer.indentln("new " + menuClass +
                         "(this).createMenu(callback);");
         writer.println("}");
      }
   }

   private void emitShortcuts(SourceWriter writer) throws UnableToCompleteException
   {
      writer.println("private void __registerShortcuts() {");
      writer.indent();
      NodeList nodes = getConfigDoc("/commands/shortcuts");
      for (int i = 0; i < nodes.getLength(); i++)
      {
         NodeList groups = nodes.item(i).getChildNodes();
         for (int j = 0; j < groups.getLength(); j++)
         {
            if (groups.item(j).getNodeType() != Node.ELEMENT_NODE)
               continue;
            String groupName = ((Element) groups.item(j)).getAttribute("name");
            new ShortcutsEmitter(logger_, groupName,
                                 (Element) groups.item(j)).generate(writer);
         }
      }
      writer.outdent();
      writer.println("}");
   }

   private JMethod[] getMethods(boolean includeCommands,
                                boolean includeMenus,
                                boolean includeShortcuts)
         throws UnableToCompleteException
   {
      ArrayList<JMethod> methods = new ArrayList<>();
      for (JMethod method : bundleType_.getMethods())
      {
         if (!method.isAbstract())
            continue;
         validateMethod(method);
         if (!includeCommands && isCommandMethod(method))
            continue;
         if (!includeMenus && isMenuMethod(method))
            continue;
         if (!includeShortcuts && isShortcutsMethod(method))
            continue;
         methods.add(method);
      }
      return methods.toArray(new JMethod[methods.size()]);
   }

   // Log and throw if anything is awry about the declaration
   private void validateMethod(JMethod method) throws UnableToCompleteException
   {
      if (isMenuMethod(method))
      {
         if (method.getParameters().length != 1)
         {
            logger_.log(TreeLogger.Type.ERROR,
                        "Method " + method +
                        " had the wrong number of parameters (expected 1)");
            throw new UnableToCompleteException();
         }

         String paramType =
               method.getParameters()[0].getType().getQualifiedSourceName();
         if (!paramType.equals("org.rstudio.core.client.command.MenuCallback"))
         {
            logger_.log(TreeLogger.Type.ERROR,
                        "Method " + method +
                        " had wrong parameter type (expected " +
                        "org.rstudio.core.client.command.MenuCallback)");
            throw new UnableToCompleteException();
         }
      }
      else
      {
         if (method.getParameters().length != 0)
         {
            logger_.log(TreeLogger.Type.ERROR,
                        "Method " + method +
                        " had parameters where none were expected");
            throw new UnableToCompleteException();
         }
      }

      if (!isCommandMethod(method)
          && !isMenuMethod(method)
          && !isShortcutsMethod(method))
      {
         logger_.log(TreeLogger.Type.ERROR,
                     "Method " + method +
                     " had an unexpected return type");
         throw new UnableToCompleteException();
      }
   }

   private boolean isCommandMethod(JMethod method)
   {
      return method.getReturnType().getQualifiedSourceName().equals(
            "org.rstudio.core.client.command.AppCommand");
   }

   private boolean isMenuMethod(JMethod method)
   {
      String sourceName = method.getReturnType().getQualifiedSourceName();
      return sourceName.equals("void");
   }

   private boolean isShortcutsMethod(JMethod method)
   {
      String sourceName = method.getReturnType().getQualifiedSourceName();
      return sourceName.equals("org.rstudio.core.client.command.ShortcutManager");
   }

   /**
    * Emit the getter method for the command--it is implemented as a cached
    * lazy-load. e.g.:
    *
    * public AppCommand newSourceDoc() {
    *   if (newSourceDoc_ == null) {
    *     newSourceDoc_ = new AppCommand();
    *     // call various setters...
    *   }
    *   return newSourceDoc_;
    * }
    */
   private void emitCommandInitializers(SourceWriter writer,
                                  JMethod method,
                                  ImageResourceInfo images)
   {
      String name = method.getName();
      writer.println(name + "_ = new AppCommand();");

      setProperty(writer, name, commandProps_.get(name), "id");
      setProperty(writer, name, commandProps_.get(name), "desc", true);
      setProperty(writer, name, commandProps_.get(name), "label", true);
      setProperty(writer, name, commandProps_.get(name), "buttonLabel", true);
      setProperty(writer, name, commandProps_.get(name), "menuLabel", true);
      setProperty(writer, name, commandProps_.get(name), "windowMode");
      setProperty(writer, name, commandProps_.get(name), "context");
      // Any additional textual properties would be added here...

      setPropertyBool(writer, name, commandProps_.get(name), "visible");
      setPropertyBool(writer, name, commandProps_.get(name), "enabled");
      setPropertyBool(writer, name, commandProps_.get(name), "checkable");
      setPropertyBool(writer, name, commandProps_.get(name), "checked");
      setPropertyBool(writer, name, commandProps_.get(name), "rebindable");
      setPropertyBool(writer, name, commandProps_.get(name), "radio");
      
      if (images.hasImage(name))
      {
        String resourceName = images.getImageRef(name);
        if (images.hasImage(name + "2x"))
          resourceName = "new ImageResource2x(" + images.getImageRef(name + "2x") + ")";

        writer.println(name + "_.setImageResource(" + resourceName + ");");
      }

      writer.println("addCommand(\"" + Generator.escape(name) + "\", " + name + "_);");
      writer.println();
   }

   private void emitCommandAccessors(SourceWriter writer)
   {
      for (JMethod method : commandMethods_)
      {
         String name = method.getName();
         writer.println("public AppCommand " + name + "() {");
         writer.indent();
         writer.println("return " + name + "_;");
         writer.outdent();
         writer.println("}");
      }
   }

   private void setProperty(SourceWriter writer,
                            String name,
                            Element props,
                            String propertyName,
                            boolean i18n)
   {
      if (!isPropertySet(props, propertyName)) {
         return;
      }

      String value;
      if (i18n)
      {
         // Set as an i18n-managed string property
         String propertyCapitalized = Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1);
         value = i18n_constants_name + "." + name + propertyCapitalized + "()";
      }
      else
      {
         // Set as a hard-coded string property
         value = "\"" + Generator.escape(props.getAttribute(propertyName)) + "\"";
      }

      String setter = "set" + Character.toUpperCase(propertyName.charAt(0))
            + propertyName.substring(1);
      writer.println(name + "_." + setter + "(" + value + ");");
   }

   private void setProperty(SourceWriter writer,
                            String name,
                            Element props,
                            String propertyName)
   {
      setProperty(writer, name, props, propertyName, false);
   }

    /**
    * Tests if a property is set, returning true if propertyName exists in props else false
    *
    * A property set to an empty string is considered set and returns true.  If the element
    * is unset (hasAttribute != true) this returns false
    */
   private boolean isPropertySet(Element props, String propertyName)
   {
      if (props == null)
         return false;
      // This check is important because getAttribute() returns empty string
      // even if the attribute isn't present, which is not what we want. In
      // the command system, empty string is distinct from null.
      if (!props.hasAttribute(propertyName))
         return false;

      return true;
   }

   private void setPropertyBool(SourceWriter writer,
                                String name,
                                Element props,
                                String propertyName)
   {
      if (props == null)
         return;
      // This check is important because getAttribute() returns empty string
      // even if the attribute isn't present, which is not what we want. In
      // the command system, empty string is distinct from null.
      if (!props.hasAttribute(propertyName))
         return;

      String value = props.getAttribute(propertyName);

      String setter = "set" + Character.toUpperCase(propertyName.charAt(0))
            + propertyName.substring(1);
      writer.println(name + "_." + setter
                     + "(" + value + ");");
   }

   private ImageResourceInfo generateImageBundle()
   {
      String className = bundleType_.getSimpleSourceName() + "__AutoGenResources";
      String pathToInstance = packageName_ + "." + className + ".INSTANCE";
      ImageResourceInfo iri = new ImageResourceInfo(pathToInstance);

      PrintWriter printWriter = context_.tryCreate(logger_,
                                                  packageName_,
                                                  className);
      if (printWriter == null)
         return null;

      ClassSourceFileComposerFactory factory =
            new ClassSourceFileComposerFactory(packageName_, className);
      factory.addImport("com.google.gwt.core.client.GWT");
      factory.addImport("com.google.gwt.resources.client.*");
      factory.makeInterface();
      factory.addImplementedInterface("ClientBundle");
      SourceWriter writer = factory.createSourceWriter(context_, printWriter);

      Set<String> resourceNames = context_.getResourcesOracle().getPathNames();
      for (JMethod method : commandMethods_)
      {
         String commandId = method.getName();

         String key = packageName_.replace('.', '/') + "/" + commandId;

         if (resourceNames.contains(key + ".png"))
         {
            writer.println("ImageResource " + commandId + "();");
            iri.addImage(commandId);
         }

         if (resourceNames.contains(key + "_2x.png"))
         {
            writer.println("@Source(\"" + commandId + "_2x.png\")");
            writer.println("ImageResource " + commandId + "2x();");
            iri.addImage(commandId + "2x");
         }
      }
      writer.println("public static final " + className + " INSTANCE = " +
                     "(" + className + ")GWT.create(" + className + ".class);");

      writer.outdent();
      writer.println("}");
      context_.commit(logger_, printWriter);

      return iri;

   }

   public Map<String, Element> getCommandProperties() throws UnableToCompleteException
   {
      Map<String, Element> properties = new HashMap<>();

      NodeList nodes = getConfigDoc("/commands/cmd");

      for (int i = 0; i < nodes.getLength(); i++)
      {
         Element cmd = (Element) nodes.item(i);
         String id = cmd.getAttribute("id");

         properties.put(id, cmd);
      }

      return properties;
   }

   private NodeList getConfigDoc(String xpath) throws UnableToCompleteException
   {
      try
      {
         String resourceName =
               bundleType_.getQualifiedSourceName().replace('.', '/') + ".cmd.xml";
         Resource resource = context_.getResourcesOracle().getResource(resourceName);
         if (resource == null)
            return null;

         Object result = XPathFactory.newInstance().newXPath().evaluate(
               xpath,
               new InputSource(resource.getLocation()),
               XPathConstants.NODESET);
         return (NodeList) result;
      }
      catch (Exception e)
      {
         logger_.log(TreeLogger.Type.ERROR, "Barf", e);
         throw new UnableToCompleteException();
      }
   }

   private final TreeLogger logger_;
   private final GeneratorContext context_;
   private final JClassType bundleType_;
   private final JMethod[] commandMethods_;
   private final JMethod[] menuMethods_;
   @SuppressWarnings("unused")
   private final JMethod[] shortcutsMethods_;
   private final String packageName_;
   private final Map<String, Element> commandProps_;
   private String simpleName_;
   private final String i18n_constants_name = "_constants";
}

class ImageResourceInfo
{
   public ImageResourceInfo(String imagesRefPath)
   {
      this.imagesRefPath_ = imagesRefPath;
   }

   public void addImage(String commandId)
   {
      imageIds_.add(commandId);
   }

   public boolean hasImage(String commandId)
   {
      return imageIds_.contains(commandId);
   }

   public String getImageRef(String commandId)
   {
      assert hasImage(commandId);
      return imagesRefPath_ + "." + commandId + "()";
   }

   private final String imagesRefPath_;
   private final HashSet<String> imageIds_ = new HashSet<>();
}
