/*
 * 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 net.revelc.code.impsort;

import static com.github.javaparser.javadoc.JavadocBlockTag.Type.EXCEPTION;
import static com.github.javaparser.javadoc.JavadocBlockTag.Type.THROWS;

import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import com.github.javaparser.JavaParser;
import com.github.javaparser.JavaToken;
import com.github.javaparser.ParseResult;
import com.github.javaparser.Position;
import com.github.javaparser.TokenRange;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.ImportDeclaration;
import com.github.javaparser.ast.Node;
import com.github.javaparser.ast.NodeList;
import com.github.javaparser.ast.PackageDeclaration;
import com.github.javaparser.ast.body.TypeDeclaration;
import com.github.javaparser.ast.comments.Comment;
import com.github.javaparser.ast.comments.JavadocComment;
import com.github.javaparser.javadoc.Javadoc;
import com.github.javaparser.javadoc.JavadocBlockTag;
import com.github.javaparser.javadoc.description.JavadocDescription;
import com.github.javaparser.javadoc.description.JavadocInlineTag;
import com.github.javaparser.javadoc.description.JavadocSnippet;

public class ImpSort {

  private static final Comparator<Node> BY_POSITION = Comparator.comparing(a -> a.getBegin().get());

  private final Charset sourceEncoding;
  private final Grouper grouper;
  private final boolean removeUnused;
  private final boolean treatSamePackageAsUnused;
  private final LineEnding lineEnding;

  public ImpSort(final Charset sourceEncoding, final Grouper grouper, final boolean removeUnused,
      final boolean treatSamePackageAsUnused, final LineEnding lineEnding) {
    this.sourceEncoding = sourceEncoding;
    this.grouper = grouper;
    this.removeUnused = removeUnused;
    this.treatSamePackageAsUnused = treatSamePackageAsUnused;
    this.lineEnding = lineEnding;
  }

  public Result parseFile(final Path path) throws IOException {
    String file = new String(Files.readAllBytes(path), sourceEncoding);
    LineEnding fileLineEnding = LineEnding.determineLineEnding(file);
    LineEnding impLineEnding;
    if (lineEnding == LineEnding.KEEP) {
      impLineEnding = fileLineEnding;
    } else {
      impLineEnding = lineEnding;
    }
    List<String> fileLines = Arrays.asList(file.split(fileLineEnding.getChars()));
    ParseResult<CompilationUnit> parseResult = new JavaParser().parse(file);
    CompilationUnit unit =
        parseResult.getResult().orElseThrow(() -> new IOException("Unable to parse " + path));
    Position packagePosition =
        unit.getPackageDeclaration().map(p -> p.getEnd().get()).orElse(unit.getBegin().get());
    NodeList<ImportDeclaration> importDeclarations = unit.getImports();
    if (importDeclarations.isEmpty()) {
      return new Result(path, sourceEncoding, fileLines, 0, fileLines.size(), "", "",
          Collections.emptyList(), impLineEnding);
    }

    // find orphaned comments before between package and last import
    Position lastImportPosition =
        importDeclarations.stream().max(BY_POSITION).get().getBegin().get();
    Stream<Comment> orphanedComments = unit.getOrphanComments().parallelStream().filter(c -> {
      Position p = c.getBegin().get();
      return p.isAfter(packagePosition) && p.isBefore(lastImportPosition);
    });

    // create entire import section (with interspersed comments)
    List<Node> importSectionNodes =
        Stream.concat(orphanedComments, importDeclarations.stream()).collect(Collectors.toList());
    importSectionNodes.sort(BY_POSITION);
    // position line numbers start at 1, not 0
    int start = importSectionNodes.get(0).getBegin().get().line - 1;
    int stop = importSectionNodes.get(importSectionNodes.size() - 1).getEnd().get().line;
    // get the original import section lines from the file
    // include surrounding whitespace
    while (start > 0 && fileLines.get(start - 1).trim().isEmpty()) {
      --start;
    }
    while (stop < fileLines.size() && fileLines.get(stop).trim().isEmpty()) {
      ++stop;
    }
    String originalSection = String.join(impLineEnding.getChars(), fileLines.subList(start, stop))
        + impLineEnding.getChars();

    Set<Import> allImports = convertImportSection(importSectionNodes, impLineEnding.getChars());

    if (removeUnused) {
      removeUnusedImports(allImports, tokensInUse(unit));
      if (treatSamePackageAsUnused) {
        removeSamePackageImports(allImports, unit.getPackageDeclaration());
      }
    }

    String newSection = grouper.groupedImports(allImports, impLineEnding.getChars());
    if (start > 0) {
      // add newline before imports, as long as imports not at start of file
      newSection = impLineEnding.getChars() + newSection;
    }
    if (stop < fileLines.size()) {
      // add newline after imports, as long as there's more in file
      newSection += impLineEnding.getChars();
    }

    return new Result(path, sourceEncoding, fileLines, start, stop, originalSection, newSection,
        allImports, impLineEnding);
  }

  // return imports, with associated comments, in order found in the file
  private static Set<Import> convertImportSection(List<Node> importSectionNodes, String eol) {
    List<Comment> recentComments = new ArrayList<>();
    LinkedHashSet<Import> allImports = new LinkedHashSet<>(importSectionNodes.size() / 2);
    for (Node node : importSectionNodes) {
      if (node instanceof Comment) {
        recentComments.add((Comment) node);
      } else if (node instanceof ImportDeclaration) {
        List<Node> thisImport = new ArrayList<>(2);
        ImportDeclaration impDecl = (ImportDeclaration) node;
        thisImport.addAll(recentComments);

        Optional<Comment> impComment = impDecl.getComment();
        if (impComment.isPresent()) {
          Comment c = impComment.get();
          if (c.getBegin().get().isBefore(impDecl.getBegin().get())) {
            thisImport.add(c);
            thisImport.add(impDecl);
          } else {
            thisImport.add(impDecl);
            thisImport.add(c);
          }
        } else {
          thisImport.add(impDecl);
        }

        recentComments.clear();
        convertAndAddImport(allImports, thisImport, eol);
      } else {
        throw new IllegalStateException("Unknown node: " + node);
      }
    }
    if (!recentComments.isEmpty()) {
      throw new IllegalStateException(
          "Unexpectedly found more orphaned comments: " + recentComments);
    }
    return allImports;
  }

  private static void convertAndAddImport(LinkedHashSet<Import> allImports, List<Node> thisImport,
      String eol) {
    boolean isStatic = false;
    String importItem = null;
    String prefix = "";
    String suffix = "";
    for (Node n : thisImport) {
      if (n instanceof Comment) {
        if (importItem == null) {
          prefix += n.toString();
        } else {
          suffix += n.toString();
        }
      }
      if (n instanceof ImportDeclaration) {
        ImportDeclaration i = (ImportDeclaration) n;
        isStatic = i.isStatic();
        importItem = i.getName().asString() + (i.isAsterisk() ? ".*" : "");
      }
    }
    suffix = suffix.trim();
    if (!suffix.isEmpty()) {
      suffix = " " + suffix;
    }
    Import imp = new Import(isStatic, importItem, prefix.trim(), suffix, eol);
    Iterator<Import> iter = allImports.iterator();
    // this de-duplication can probably be made more efficient by doing it all at the end
    while (iter.hasNext()) {
      Import candidate = iter.next(); // potential duplicate
      if (candidate.isDuplicatedBy(imp)) {
        iter.remove();
        imp = candidate.combineWith(imp);
      }
    }
    allImports.add(imp);
  }

  /*
   * Extract all of the tokens from the main body of the file.
   *
   * This set of tokens represents all of the file's dependencies, and is used to figure out whether
   * or not an import is unused.
   */
  private static Set<String> tokensInUse(CompilationUnit unit) {

    // Extract tokens from the java code:
    Stream<Node> packageDecl =
        unit.getPackageDeclaration().isPresent()
            ? Stream.of(unit.getPackageDeclaration().get()).map(PackageDeclaration::getAnnotations)
                .flatMap(NodeList::stream)
            : Stream.empty();
    Stream<String> typesInCode = Stream.concat(packageDecl, unit.getTypes().stream())
        .map(Node::getTokenRange).filter(Optional::isPresent).map(Optional::get)
        .filter(r -> r != TokenRange.INVALID).flatMap(r -> {
          // get all JavaTokens as strings from each range
          return StreamSupport.stream(r.spliterator(), false);
        }).map(JavaToken::asString);

    // Extract referenced class names from parsed javadoc comments:
    Stream<String> typesInJavadocs = unit.getAllComments().stream()
        .filter(c -> c instanceof JavadocComment).map(JavadocComment.class::cast)
        .map(JavadocComment::parse).flatMap(ImpSort::parseJavadoc);

    return Stream.concat(typesInCode, typesInJavadocs)
        .filter(t -> t != null && !t.isEmpty() && Character.isJavaIdentifierStart(t.charAt(0)))
        .collect(Collectors.toSet());
  }

  // parse both main doc description and any block tags
  private static Stream<String> parseJavadoc(Javadoc javadoc) {
    // parse main doc description
    Stream<String> stringsFromJavadocDescription =
        Stream.of(javadoc.getDescription()).flatMap(ImpSort::parseJavadocDescription);
    // grab tag names and parsed descriptions for block tags
    Stream<String> stringsFromBlockTags = javadoc.getBlockTags().stream().flatMap(tag -> {
      // only @throws and @exception have names who are importable; @param and others don't
      EnumSet<JavadocBlockTag.Type> blockTagTypesWithImportableNames =
          EnumSet.of(THROWS, EXCEPTION);
      Stream<String> importableTagNames = blockTagTypesWithImportableNames.contains(tag.getType())
          ? Stream.of(tag.getName()).filter(Optional::isPresent).map(Optional::get)
          : Stream.empty();
      Stream<String> tagDescriptions =
          Stream.of(tag.getContent()).flatMap(ImpSort::parseJavadocDescription);
      return Stream.concat(importableTagNames, tagDescriptions);
    });
    return Stream.concat(stringsFromJavadocDescription, stringsFromBlockTags);
  }

  private static Stream<String> parseJavadocDescription(JavadocDescription description) {
    return description.getElements().stream().map(element -> {
      if (element instanceof JavadocInlineTag) {
        // inline tags like {@link Foo}
        return ((JavadocInlineTag) element).getContent();
      } else if (element instanceof JavadocSnippet) {
        // snippets like @see Foo
        return element.toText();
      } else {
        // try to handle unknown elements as best we can
        return element.toText();
      }
    }).flatMap(s -> {
      // split text descriptions into word tokens
      return Stream.of(s.split("\\W+"));
    });
  }

  /*
   * Remove unused imports.
   *
   * This algorithm only looks at the file itself, and evaluates whether or not a given import is
   * unused, by checking if the last segment of the import path (typically a class name or a static
   * function name) appears in the file.
   *
   * This means that it is not possible to remove import statements with wildcards.
   */
  private static void removeUnusedImports(Set<Import> imports, Set<String> tokensInUse) {
    imports.removeIf(i -> {
      String[] segments = i.getImport().split("[.]");
      if (segments.length == 0) {
        throw new AssertionError("Parse tree includes invalid import statements");
      }

      String lastSegment = segments[segments.length - 1];
      if (lastSegment.equals("*")) {
        return false;
      }

      return !tokensInUse.contains(lastSegment);
    });
  }

  static void removeSamePackageImports(Set<Import> imports,
      Optional<PackageDeclaration> packageDeclaration) {
    String packageName = packageDeclaration.map(p -> p.getName().toString()).orElse("");
    imports.removeIf(i -> {
      String imp = i.getImport();
      if (packageName.isEmpty()) {
        return !imp.contains(".");
      }
      return imp.startsWith(packageName) && imp.lastIndexOf(".") <= packageName.length();
    });

  }

}