/**
 * Copyright (C) 2005 - 2020  Eric Van Dewoestine
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package org.eclim.plugin.jdt.command.doc;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;

import java.util.HashMap;
import java.util.List;

import java.util.regex.Pattern;

import org.apache.commons.lang3.StringUtils;

import org.eclim.Services;

import org.eclim.annotation.Command;

import org.eclim.command.CommandLine;
import org.eclim.command.Options;

import org.eclim.plugin.core.command.AbstractCommand;

import org.eclim.plugin.core.preference.Preferences;

import org.eclim.plugin.core.util.ProjectUtils;

import org.eclim.plugin.jdt.util.ASTUtils;
import org.eclim.plugin.jdt.util.JavaUtils;
import org.eclim.plugin.jdt.util.MethodUtils;
import org.eclim.plugin.jdt.util.TypeInfo;
import org.eclim.plugin.jdt.util.TypeUtils;

import org.eclim.util.IOUtils;

import org.eclipse.core.resources.IProject;

import org.eclipse.jdt.core.IAnnotation;
import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.IJavaElement;
import org.eclipse.jdt.core.IMethod;
import org.eclipse.jdt.core.IType;
import org.eclipse.jdt.core.Signature;

import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.BodyDeclaration;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.Javadoc;
import org.eclipse.jdt.core.dom.Name;
import org.eclipse.jdt.core.dom.PackageDeclaration;
import org.eclipse.jdt.core.dom.TagElement;
import org.eclipse.jdt.core.dom.TextElement;

import org.eclipse.jdt.core.formatter.CodeFormatter;

/**
 * Handles requests to add javadoc comments to an element.
 *
 * @author Eric Van Dewoestine
 */
@Command(
  name = "javadoc_comment",
  options =
    "REQUIRED p project ARG," +
    "REQUIRED f file ARG," +
    "REQUIRED o offset ARG," +
    "OPTIONAL e encoding ARG"
)
public class CommentCommand
  extends AbstractCommand
{
  private static final Pattern THROWS_PATTERN =
    Pattern.compile("\\s*[a-zA-Z0-9._]*\\.(\\w*)($|\\s.*)");

  private static final String INHERIT_DOC =
    "{" + TagElement.TAG_INHERITDOC + "}";

  @Override
  public Object execute(CommandLine commandLine)
    throws Exception
  {
    String project = commandLine.getValue(Options.PROJECT_OPTION);
    String file = commandLine.getValue(Options.FILE_OPTION);
    int offset = getOffset(commandLine);

    ICompilationUnit src = JavaUtils.getCompilationUnit(project, file);
    IJavaElement element = src.getElementAt(offset);
    // don't comment import declarations.
    if(element.getElementType() == IJavaElement.IMPORT_DECLARATION){
      return null;
    }

    CompilationUnit cu = ASTUtils.getCompilationUnit(src, true);
    ASTNode node = ASTUtils.findNode(cu, offset, element);

    if(node != null){
      comment(src, node, element);

      ASTUtils.commitCompilationUnit(src, cu);

      // re-grab the compilation unit + node so we can get the javadoc node w/
      // its position and length set.
      cu = ASTUtils.getCompilationUnit(src, true);
      node = ASTUtils.findNode(cu, offset, element);
      Javadoc javadoc = (node instanceof PackageDeclaration) ?
        ((PackageDeclaration)node).getJavadoc() :
        ((BodyDeclaration)node).getJavadoc();
      int kind = CodeFormatter.K_COMPILATION_UNIT |
        CodeFormatter.F_INCLUDE_COMMENTS;
      int start = javadoc.getStartPosition();
      int length = javadoc.getLength();
      JavaUtils.format(src, kind, start, javadoc.getLength());
    }

    return null;
  }

  /**
   * Comment the supplied node.
   *
   * @param src The source file.
   * @param node The node to comment.
   * @param element The IJavaElement this node corresponds to.
   */
  private void comment(ICompilationUnit src, ASTNode node, IJavaElement element)
    throws Exception
  {
    Javadoc javadoc = null;
    boolean isNew = false;
    if (node instanceof PackageDeclaration){
      javadoc = ((PackageDeclaration)node).getJavadoc();
      if(javadoc == null){
        isNew = true;
        javadoc = node.getAST().newJavadoc();
        ((PackageDeclaration)node).setJavadoc(javadoc);
      }
    }else{
      javadoc = ((BodyDeclaration)node).getJavadoc();
      if(javadoc == null){
        isNew = true;
        javadoc = node.getAST().newJavadoc();
        ((BodyDeclaration)node).setJavadoc(javadoc);
      }
    }

    switch(node.getNodeType()){
      case ASTNode.PACKAGE_DECLARATION:
        commentPackage(src, javadoc, element, isNew);
        break;
      case ASTNode.ENUM_DECLARATION:
      case ASTNode.TYPE_DECLARATION:
        commentType(src, javadoc, element, isNew);
        break;
      case ASTNode.METHOD_DECLARATION:
        commentMethod(src, javadoc, element, isNew);
        break;
      default:
        commentOther(src, javadoc, element, isNew);
    }
  }

  /**
   * Comment a package declaration.
   *
   * @param src The source file.
   * @param javadoc The Javadoc.
   * @param element The IJavaElement.
   * @param isNew true if there was no previous javadoc for this element.
   */
  private void commentPackage(
      ICompilationUnit src, Javadoc javadoc, IJavaElement element, boolean isNew)
    throws Exception
  {
    IProject project = element.getJavaProject().getProject();
    String copyright = getPreferences().getValue(
      project, Preferences.PROJECT_COPYRIGHT_PREFERENCE);
    if(copyright != null && copyright.trim().length() > 0){
      File file = new File(ProjectUtils.getFilePath(project, copyright));
      if(!file.exists()){
        throw new IllegalArgumentException(
            Services.getMessage("project.copyright.not.found", file));
      }

      @SuppressWarnings("unchecked")
      List<TagElement> tags = javadoc.tags();
      tags.clear();

      BufferedReader reader = null;
      try{
        reader = new BufferedReader(new FileReader(file));
        String line = null;
        while ((line = reader.readLine()) != null){
          addTag(javadoc, tags.size(), null, line);
        }
      }finally{
        IOUtils.closeQuietly(reader);
      }
    }else{
      commentOther(src, javadoc, element, isNew);
    }
  }

  /**
   * Comment a type declaration.
   *
   * @param src The source file.
   * @param javadoc The Javadoc.
   * @param element The IJavaElement.
   * @param isNew true if there was no previous javadoc for this element.
   */
  private void commentType(
      ICompilationUnit src, Javadoc javadoc, IJavaElement element, boolean isNew)
    throws Exception
  {
    if(element.getParent().getElementType() == IJavaElement.COMPILATION_UNIT){
      @SuppressWarnings("unchecked")
      List<TagElement> tags = javadoc.tags();
      IProject project = element.getJavaProject().getProject();
      if(isNew){
        addTag(javadoc, tags.size(), null, null);
        addTag(javadoc, tags.size(), null, null);
        addTag(javadoc, tags.size(), TagElement.TAG_AUTHOR, getAuthor(project));
        String version = getPreferences().getValue(
            project, "org.eclim.java.doc.version");
        if(version != null && !StringUtils.EMPTY.equals(version.trim())){
          version = StringUtils.replace(version, "\\$", "$");
          addTag(javadoc, tags.size(), TagElement.TAG_VERSION, version);
        }
      }else{
        // check if author tag exists.
        int index = -1;
        String author = getAuthor(project);
        for (int ii = 0; ii < tags.size(); ii++){
          TagElement tag = (TagElement)tags.get(ii);
          if(TagElement.TAG_AUTHOR.equals(tag.getTagName())){
            String authorText = tag.fragments().size() > 0 ?
              ((TextElement)tag.fragments().get(0)).getText() : null;
            // check if author tag is the same.
            if(authorText != null && author.trim().equals(authorText.trim())){
              index = -1;
              break;
            }
            index = ii + 1;
          }else if(tag.getTagName() != null){
            if(index == -1){
              index = ii;
            }
          }
        }

        // insert author tag if it doesn't exist.
        if(index > -1){
          TagElement authorTag = javadoc.getAST().newTagElement();
          TextElement authorText = javadoc.getAST().newTextElement();
          authorText.setText(author);
          authorTag.setTagName(TagElement.TAG_AUTHOR);

          @SuppressWarnings("unchecked")
          List<ASTNode> fragments = authorTag.fragments();
          fragments.add(authorText);
          tags.add(index, authorTag);
        }

        // add the version tag if it doesn't exist.
        boolean versionExists = false;
        for (int ii = 0; ii < tags.size(); ii++){
          TagElement tag = (TagElement)tags.get(ii);
          if(TagElement.TAG_VERSION.equals(tag.getTagName())){
            versionExists = true;
            break;
          }
        }
        if (!versionExists){
          String version = getPreferences().getValue(
              project, "org.eclim.java.doc.version");
          version = StringUtils.replace(version, "\\$", "$");
          addTag(javadoc, tags.size(), TagElement.TAG_VERSION, version);
        }
      }
    }else{
      commentOther(src, javadoc, element, isNew);
    }
  }

  /**
   * Comment a method declaration.
   *
   * @param src The source file.
   * @param javadoc The Javadoc.
   * @param element The IJavaElement.
   * @param isNew true if there was no previous javadoc for this element.
   */
  private void commentMethod(
      ICompilationUnit src, Javadoc javadoc, IJavaElement element, boolean isNew)
    throws Exception
  {
    @SuppressWarnings("unchecked")
    List<TagElement> tags = javadoc.tags();
    IMethod method = (IMethod)element;
    IType type = method.getDeclaringType();

    boolean hasOverride = false;
    IAnnotation[] annotations = method.getAnnotations();
    for (IAnnotation annotation : annotations){
      for (String[] result : type.resolveType(annotation.getElementName())){
        if (result[0].equals("java.lang") && result[1].equals("Override")){
          hasOverride = true;
          break;
        }
      }
    }

    if(isNew){
      // see if method is overriding / implementing method from superclass
      IType parentType = null;
      TypeInfo[] types = TypeUtils.getSuperTypes(type);
      for (TypeInfo info : types){
        if(MethodUtils.containsMethod(info, method)){
          parentType = info.getType();
          break;
        }
      }

      // if an inherited method, add inheritDoc and @see
      if(parentType != null){
        if (!hasOverride){
          addTag(javadoc, tags.size(), null, INHERIT_DOC);

          String typeName =
            JavaUtils.getCompilationUnitRelativeTypeName(src, parentType);

          StringBuffer signature = new StringBuffer();
          signature.append(typeName)
            .append('#').append(MethodUtils.getMinimalMethodSignature(method, null));
          addTag(javadoc, tags.size(), TagElement.TAG_SEE, signature.toString());
        }
      }else{
        addTag(javadoc, tags.size(), null, null);
        addTag(javadoc, tags.size(), null, null);
      }
    }

    // only add/update tags if javadoc doesn't contain inheritDoc.
    boolean update = true;
    for (TagElement tag : tags){
      if(tag.getTagName() == null && tag.fragments().size() > 0){
        if(INHERIT_DOC.equals(tag.fragments().get(0).toString())){
          update = false;
          break;
        }
      }
    }

    if(update){
      addUpdateParamTags(javadoc, method, isNew);
      addUpdateReturnTag(javadoc, method, isNew);
      addUpdateThrowsTags(javadoc, method, isNew);
    }
  }

  /**
   * Comment everything else.
   *
   * @param src The source file.
   * @param javadoc The Javadoc.
   * @param element The IJavaElement.
   * @param isNew true if there was no previous javadoc for this element.
   */
  private void commentOther(
      ICompilationUnit src, Javadoc javadoc, IJavaElement element, boolean isNew)
    throws Exception
  {
    if(isNew){
      addTag(javadoc, 0, null, null);
    }
  }

  /**
   * Add or update the param tags for the given method.
   *
   * @param javadoc The Javadoc instance.
   * @param method The method.
   * @param isNew true if we're adding to brand new javadocs.
   */
  private void addUpdateParamTags(
      Javadoc javadoc, IMethod method, boolean isNew)
    throws Exception
  {
    @SuppressWarnings("unchecked")
    List<TagElement> tags = javadoc.tags();
    String[] params = method.getParameterNames();
    if(isNew){
      for (int ii = 0; ii < params.length; ii++){
        addTag(javadoc, tags.size(), TagElement.TAG_PARAM, params[ii]);
      }
    }else{
      // find current params.
      int index = 0;
      HashMap<String, TagElement> current = new HashMap<String, TagElement>();
      for (int ii = 0; ii < tags.size(); ii++){
        TagElement tag = (TagElement)tags.get(ii);
        if(TagElement.TAG_PARAM.equals(tag.getTagName())){
          if(current.size() == 0){
            index = ii;
          }
          Object element = tag.fragments().size() > 0 ?
            tag.fragments().get(0) : null;
          if(element != null && element instanceof Name){
            String name = ((Name)element).getFullyQualifiedName();
            current.put(name, tag);
          }else{
            current.put(String.valueOf(ii), tag);
          }
        }else{
          if(current.size() > 0){
            break;
          }
          if(tag.getTagName() == null){
            index = ii + 1;
          }
        }
      }

      if(current.size() > 0){
        for (int ii = 0; ii < params.length; ii++){
          if(current.containsKey(params[ii])){
            TagElement tag = (TagElement)current.get(params[ii]);
            int currentIndex = tags.indexOf(tag);
            if(currentIndex != ii){
              tags.remove(tag);
              tags.add(index + ii, tag);
            }
            current.remove(params[ii]);
          }else{
            addTag(javadoc, index + ii, TagElement.TAG_PARAM, params[ii]);
          }
        }

        // remove any other param tags.
        for (TagElement tag : current.values()){
          tags.remove(tag);
        }
      }else{
        for (int ii = 0; ii < params.length; ii++){
          addTag(javadoc, index + ii, TagElement.TAG_PARAM, params[ii]);
        }
      }
    }
  }

  /**
   * Add or update the return tag for the given method.
   *
   * @param javadoc The Javadoc instance.
   * @param method The method.
   * @param isNew true if we're adding to brand new javadocs.
   */
  private void addUpdateReturnTag(
      Javadoc javadoc, IMethod method, boolean isNew)
    throws Exception
  {
    @SuppressWarnings("unchecked")
    List<TagElement> tags = javadoc.tags();
    // get return type from element.
    if(!method.isConstructor()){
      String returnType =
        Signature.getSignatureSimpleName(method.getReturnType());
      if (!"void".equals(returnType)){
        if(isNew){
          addTag(javadoc, tags.size(), TagElement.TAG_RETURN, null);
        }else{
          // search starting from the bottom since @return should be near the
          // end.
          int index = tags.size();
          for (int ii = tags.size() - 1; ii >= 0; ii--){
            TagElement tag = (TagElement)tags.get(ii);
            // return tag already exists?
            if(TagElement.TAG_RETURN.equals(tag.getTagName())){
              index = -1;
              break;
            }
            // if we hit the param tags, or the main text, insert below them.
            if (TagElement.TAG_PARAM.equals(tag.getTagName()) ||
                tag.getTagName() == null)
            {
              index = ii + 1;
              break;
            }
            index = ii;
          }
          if(index > -1){
            addTag(javadoc, index, TagElement.TAG_RETURN, null);
          }
        }
      }else{
        // remove any return tag that may exist.
        for (int ii = tags.size() - 1; ii >= 0; ii--){
          TagElement tag = (TagElement)tags.get(ii);
          // return tag already exists?
          if(TagElement.TAG_RETURN.equals(tag.getTagName())){
            tags.remove(tag);
          }
          // if we hit the param tags, or the main text we can stop.
          if (TagElement.TAG_PARAM.equals(tag.getTagName()) ||
              tag.getTagName() == null)
          {
            break;
          }
        }
      }
    }
  }

  /**
   * Add or update the throws tags for the given method.
   *
   * @param javadoc The Javadoc instance.
   * @param method The method.
   * @param isNew true if we're adding to brand new javadocs.
   */
  private void addUpdateThrowsTags(
      Javadoc javadoc, IMethod method, boolean isNew)
    throws Exception
  {
    @SuppressWarnings("unchecked")
    List<TagElement> tags = javadoc.tags();

    // get thrown exceptions from element.
    String[] exceptions = method.getExceptionTypes();
    if(isNew && exceptions.length > 0){
      addTag(javadoc, tags.size(), null, null);
      for (int ii = 0; ii < exceptions.length; ii++){
        addTag(javadoc, tags.size(), TagElement.TAG_THROWS,
            Signature.getSignatureSimpleName(exceptions[ii]));
      }
    }else{
      // get current throws tags
      HashMap<String, TagElement> current = new HashMap<String, TagElement>();
      int index = tags.size();
      for (int ii = tags.size() - 1; ii >= 0; ii--){
        TagElement tag = (TagElement)tags.get(ii);
        if(TagElement.TAG_THROWS.equals(tag.getTagName())){
          index = index == tags.size() ? ii + 1 : index;
          Name name = tag.fragments().size() > 0 ?
            (Name)tag.fragments().get(0) : null;
          if(name != null){
            String text = name.getFullyQualifiedName();
            String key = THROWS_PATTERN.matcher(text).replaceFirst("$1");
            current.put(key, tag);
          }else{
            current.put(String.valueOf(ii), tag);
          }
        }
        // if we hit the return tag, a param tag, or the main text we can stop.
        if (TagElement.TAG_PARAM.equals(tag.getTagName()) ||
            TagElement.TAG_RETURN.equals(tag.getTagName()) ||
            tag.getTagName() == null)
        {
          break;
        }
      }

      // see what needs to be added / removed.
      for (int ii = 0; ii < exceptions.length; ii++){
        String name = Signature.getSignatureSimpleName(exceptions[ii]);
        if(!current.containsKey(name)){
          addTag(javadoc, index, TagElement.TAG_THROWS, name);
        }else{
          current.remove(name);
        }
      }

      // remove any left over thows clauses.
      for (TagElement tag : current.values()){
        tags.remove(tag);
      }
    }
  }

  /**
   * Gets the author string.
   *
   * @return The author.
   */
  private String getAuthor(IProject project)
    throws Exception
  {
    String username = getPreferences().getValue(
        project.getProject(), Preferences.USERNAME_PREFERENCE);
    String email = getPreferences().getValue(
        project.getProject(), Preferences.USEREMAIL_PREFERENCE);

    // build the author string.
    StringBuffer author = new StringBuffer();
    if (username != null && username.trim().length() > 0){
      author.append(username);
      if (email != null && email.trim().length() > 0){
        author.append(" (")
          .append(email)
          .append(")");
      }
    }else if (email != null && email.trim().length() > 0){
      author.append(email);
    }
    return author.toString();
  }

  /**
   * Adds a tag to the supplied list of tags.
   *
   * @param javadoc The Javadoc instance.
   * @param index The index to insert the new tag at.
   * @param name The tag name.
   * @param text The tag text.
   */
  private void addTag(
      Javadoc javadoc, int index, String name, String text)
    throws Exception
  {
    TagElement tag = javadoc.getAST().newTagElement();
    tag.setTagName(name);

    if(text != null){
      TextElement textElement = javadoc.getAST().newTextElement();
      textElement.setText(text);

      @SuppressWarnings("unchecked")
      List<ASTNode> fragments = tag.fragments();
      fragments.add(textElement);
    }

    @SuppressWarnings("unchecked")
    List<TagElement> tags = javadoc.tags();
    tags.add(index, tag);
  }
}
