/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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 org.apache.lucene.missingdoclet;

import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.ModuleElement;
import javax.lang.model.element.TypeElement;
import javax.lang.model.type.DeclaredType;
import javax.lang.model.type.TypeKind;
import javax.lang.model.util.ElementFilter;
import javax.lang.model.util.Elements;
import javax.tools.Diagnostic;

import com.sun.source.doctree.DocCommentTree;
import com.sun.source.doctree.ParamTree;
import com.sun.source.util.DocTrees;

import jdk.javadoc.doclet.Doclet;
import jdk.javadoc.doclet.DocletEnvironment;
import jdk.javadoc.doclet.Reporter;
import jdk.javadoc.doclet.StandardDoclet;

/**
 * Checks for missing javadocs, where missing also means "only whitespace" or "license header".
 * Has option --missing-level (package, class, method, parameter) so that we can improve over time.
 * Has option --missing-ignore to ignore individual elements (such as split packages). 
 *   It isn't recursive, just ignores exactly the elements you tell it.
 *   This should be removed when packaging is fixed to no longer be split across JARs.
 * Has option --missing-method to apply "method" level to selected packages (fix one at a time).
 *   Matches package names exactly: so you'll need to list subpackages separately.
 */
public class MissingDoclet extends StandardDoclet {
  // checks that modules and packages have documentation
  private static final int PACKAGE = 0;
  // + checks that classes, interfaces, enums, and annotation types have documentation
  private static final int CLASS = 1;
  // + checks that methods, constructors, fields, and enumerated constants have documentation
  private static final int METHOD = 2;
  // + checks that @param tags are present for any method/constructor parameters
  private static final int PARAMETER = 3;
  int level = PARAMETER;
  Reporter reporter;
  DocletEnvironment docEnv;
  DocTrees docTrees;
  Elements elementUtils;
  Set<String> ignored = Collections.emptySet();
  Set<String> methodPackages = Collections.emptySet();
  
  @Override
  public Set<Doclet.Option> getSupportedOptions() {
    Set<Doclet.Option> options = new HashSet<>();
    options.addAll(super.getSupportedOptions());
    options.add(new Doclet.Option() {
      @Override
      public int getArgumentCount() {
        return 1;
      }

      @Override
      public String getDescription() {
        return "level to enforce for missing javadocs: [package, class, method, parameter]";
      }

      @Override
      public Kind getKind() {
        return Option.Kind.STANDARD;
      }

      @Override
      public List<String> getNames() {
        return Collections.singletonList("--missing-level");
      }

      @Override
      public String getParameters() {
        return "level";
      }

      @Override
      public boolean process(String option, List<String> arguments) {
        switch (arguments.get(0)) {
          case "package":
            level = PACKAGE;
            return true;
          case "class":
            level = CLASS;
            return true;
          case "method":
            level = METHOD;
            return true;
          case "parameter":
            level = PARAMETER;
            return true;
          default:
            return false;
        }
      }
    });
    options.add(new Doclet.Option() {
      @Override
      public int getArgumentCount() {
        return 1;
      }

      @Override
      public String getDescription() {
        return "comma separated list of element names to ignore (e.g. as a workaround for split packages)";
      }

      @Override
      public Kind getKind() {
        return Option.Kind.STANDARD;
      }

      @Override
      public List<String> getNames() {
        return Collections.singletonList("--missing-ignore");
      }

      @Override
      public String getParameters() {
        return "ignoredNames";
      }

      @Override
      public boolean process(String option, List<String> arguments) {
        ignored = new HashSet<>(Arrays.asList(arguments.get(0).split(",")));
        return true;
      }
    });
    options.add(new Doclet.Option() {
      @Override
      public int getArgumentCount() {
        return 1;
      }

      @Override
      public String getDescription() {
        return "comma separated list of packages to check at 'method' level";
      }

      @Override
      public Kind getKind() {
        return Option.Kind.STANDARD;
      }

      @Override
      public List<String> getNames() {
        return Collections.singletonList("--missing-method");
      }

      @Override
      public String getParameters() {
        return "packages";
      }

      @Override
      public boolean process(String option, List<String> arguments) {
        methodPackages = new HashSet<>(Arrays.asList(arguments.get(0).split(",")));
        return true;
      }
    });
    return options;
  }

  @Override
  public void init(Locale locale, Reporter reporter) {
    this.reporter = reporter;
    super.init(locale, reporter);
  }

  @Override
  public boolean run(DocletEnvironment docEnv) {
    this.docEnv = docEnv;
    this.docTrees = docEnv.getDocTrees();
    this.elementUtils = docEnv.getElementUtils();
    for (var element : docEnv.getIncludedElements()) {
      check(element);
    }

    return super.run(docEnv);
  }
  
  /**
   * Returns effective check level for this element
   */
  private int level(Element element) {
    String pkg = elementUtils.getPackageOf(element).getQualifiedName().toString();
    if (methodPackages.contains(pkg)) {
      return METHOD;
    } else {
      return level;
    }
  }
  
  /** 
   * Check an individual element.
   * This checks packages and types from the doctrees.
   * It will recursively check methods/fields from encountered types when the level is "method"
   */
  private void check(Element element) {
    switch(element.getKind()) {
      case MODULE:
        // don't check the unnamed module, it won't have javadocs
        if (!((ModuleElement)element).isUnnamed()) {
          checkComment(element);
        }
        break;
      case PACKAGE:
        checkComment(element);
        break;
      // class-like elements, check them, then recursively check their children (fields and methods)
      case CLASS:
      case INTERFACE:
      case ENUM:
      case ANNOTATION_TYPE:
        if (level(element) >= CLASS) {
          checkComment(element);
          for (var subElement : element.getEnclosedElements()) {
            // don't recurse into enclosed types, otherwise we'll double-check since they are already in the included docTree
            if (subElement.getKind() == ElementKind.METHOD || 
                subElement.getKind() == ElementKind.CONSTRUCTOR || 
                subElement.getKind() == ElementKind.FIELD || 
                subElement.getKind() == ElementKind.ENUM_CONSTANT) {
              check(subElement);
            }
          }
        }
        break;
      // method-like elements, check them if we are configured to do so
      case METHOD:
      case CONSTRUCTOR:
      case FIELD:
      case ENUM_CONSTANT:
        if (level(element) >= METHOD && !isSyntheticEnumMethod(element)) {
          checkComment(element);
        }
        break;
      default:
        error(element, "I don't know how to analyze " + element.getKind() + " yet.");
    }
  }

  /**
   * Return true if the method is synthetic enum method (values/valueOf).
   * According to the doctree documentation, the "included" set never includes synthetic elements.
   * UweSays: It should not happen but it happens!
   */
  private boolean isSyntheticEnumMethod(Element element) {
    String simpleName = element.getSimpleName().toString();
    if (simpleName.equals("values") || simpleName.equals("valueOf")) {
      if (element.getEnclosingElement().getKind() == ElementKind.ENUM) {
        return true;
      }
    }
    return false;
  }
  
  /**
   * Checks that an element doesn't have missing javadocs.
   * In addition to truly "missing", check that comments aren't solely whitespace (generated by some IDEs),
   * that they aren't a license header masquerading as a javadoc comment.
   */
  private void checkComment(Element element) {
    // sanity check that the element is really "included", because we do some recursion into types
    if (!docEnv.isIncluded(element)) {
      return;
    }
    // check that this element isn't on our ignore list. This is only used as a workaround for "split packages".
    // ignoring a package isn't recursive (on purpose), we still check all the classes, etc. inside it.
    // we just need to cope with the fact package-info.java isn't there because it is split across multiple jars.
    if (ignored.contains(element.toString())) {
      return;
    }
    var tree = docTrees.getDocCommentTree(element);
    if (tree == null || tree.getFirstSentence().isEmpty()) {
      // Check for methods that override other stuff and perhaps inherit their Javadocs.
      if (hasInheritedJavadocs(element)) {
        return;
      } else {
        error(element, "javadocs are missing");
      }
    } else {
      var normalized = tree.getFirstSentence().get(0).toString()
                       .replace('\u00A0', ' ')
                       .trim()
                       .toLowerCase(Locale.ROOT);
      if (normalized.isEmpty()) {
        error(element, "blank javadoc comment");
      } else if (normalized.startsWith("licensed to the apache software foundation") ||
                 normalized.startsWith("copyright 2004 the apache software foundation")) {
        error(element, "comment is really a license");
      }
    }
    if (level >= PARAMETER) {
      checkParameters(element, tree);
    }
  }

  private boolean hasInheritedJavadocs(Element element) {
    boolean hasOverrides = element.getAnnotationMirrors().stream()
        .anyMatch(ann -> ann.getAnnotationType().toString().equals(Override.class.getName()));

    if (hasOverrides) {
      // If an element has explicit @Overrides annotation, assume it does
      // have inherited javadocs somewhere.
      reporter.print(Diagnostic.Kind.NOTE, element, "javadoc empty but @Override declared, skipping.");
      return true;
    }

    // Check for methods up the types tree.
    if (element instanceof ExecutableElement) {
      ExecutableElement thisMethod = (ExecutableElement) element;
      Iterable<Element> superTypes =
          () -> superTypeForInheritDoc(thisMethod.getEnclosingElement()).iterator();

      for (Element sup : superTypes) {
        for (ExecutableElement supMethod : ElementFilter.methodsIn(sup.getEnclosedElements())) {
          TypeElement clazz = (TypeElement) thisMethod.getEnclosingElement();
          if (elementUtils.overrides(thisMethod, supMethod, clazz)) {
            // We could check supMethod for non-empty javadoc here. Don't know if this makes
            // sense though as all methods will be verified in the end so it'd fail on the
            // top of the hierarchy (if empty) anyway.
            reporter.print(Diagnostic.Kind.NOTE, element, "javadoc empty but method overrides another, skipping.");
            return true;
          }
        }
      }
    }

    return false;
  }


  /* Find types from which methods in type may inherit javadoc, in the proper order.*/
  private Stream<Element> superTypeForInheritDoc(Element type) {
    TypeElement clazz = (TypeElement) type;
    List<Element> interfaces = clazz.getInterfaces()
        .stream()
        .filter(tm -> tm.getKind() == TypeKind.DECLARED)
        .map(tm -> ((DeclaredType) tm).asElement())
        .collect(Collectors.toList());

    Stream<Element> result = interfaces.stream();
    result = Stream.concat(result, interfaces.stream().flatMap(this::superTypeForInheritDoc));

    if (clazz.getSuperclass().getKind() == TypeKind.DECLARED) {
      Element superClass = ((DeclaredType) clazz.getSuperclass()).asElement();
      result = Stream.concat(result, Stream.of(superClass));
      result = Stream.concat(result, superTypeForInheritDoc(superClass));
    }

    return result;
  }

  /** Checks there is a corresponding "param" tag for each method parameter */
  private void checkParameters(Element element, DocCommentTree tree) {
    if (element instanceof ExecutableElement) {
      // record each @param that we see
      Set<String> seenParameters = new HashSet<>();
      if (tree != null) {
        for (var tag : tree.getBlockTags()) {
          if (tag instanceof ParamTree) {
            var name = ((ParamTree)tag).getName().getName().toString();
            seenParameters.add(name);
          }
        }
      }
      // now compare the method's formal parameter list against it
      for (var param : ((ExecutableElement)element).getParameters()) {
        var name = param.getSimpleName().toString();
        if (!seenParameters.contains(name)) {
          error(element, "missing javadoc @param for parameter '" + name + "'");
        }
      }
    }
  }
  
  /** logs a new error for the particular element */
  private void error(Element element, String message) {
    var fullMessage = new StringBuilder();
    switch (element.getKind()) {
      case MODULE:
      case PACKAGE:
        // for modules/packages, we don't have filename + line number, fully qualify
        fullMessage.append(element.toString());
        break;
      case METHOD:
      case CONSTRUCTOR:
      case FIELD:
      case ENUM_CONSTANT:
        // for method-like elements, include the enclosing type to make it easier
        fullMessage.append(element.getEnclosingElement().getSimpleName());
        fullMessage.append(".");
        fullMessage.append(element.getSimpleName());
        break;
      default:
        // for anything else, use a simple name
        fullMessage.append(element.getSimpleName());
        break;
    }

    fullMessage.append(" (");
    fullMessage.append(element.getKind().toString().toLowerCase(Locale.ROOT));
    fullMessage.append("): ");
    fullMessage.append(message);

    if (Runtime.version().feature() == 11 && element.getKind() == ElementKind.PACKAGE) {
      // Avoid JDK 11 bug:
      // https://issues.apache.org/jira/browse/LUCENE-9747
      // https://bugs.openjdk.java.net/browse/JDK-8224082
      reporter.print(Diagnostic.Kind.ERROR, fullMessage.toString());
    } else {
      reporter.print(Diagnostic.Kind.ERROR, element, fullMessage.toString());
    }
  }
}
