package org.checkerframework.framework.ajava;

import com.github.javaparser.JavaToken;
import com.github.javaparser.JavaToken.Kind;
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.body.FieldDeclaration;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.body.TypeDeclaration;
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.github.javaparser.ast.nodeTypes.NodeWithAnnotations;
import com.github.javaparser.ast.type.ArrayType;
import com.github.javaparser.ast.type.ArrayType.ArrayBracketPair;
import com.github.javaparser.ast.type.ClassOrInterfaceType;
import com.github.javaparser.ast.type.Type;
import com.github.javaparser.printer.DefaultPrettyPrinter;
import com.github.javaparser.utils.Pair;
import com.sun.source.util.JavacTask;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.FileVisitResult;
import java.nio.file.FileVisitor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.StringJoiner;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.PackageElement;
import javax.lang.model.element.TypeElement;
import javax.lang.model.util.Elements;
import javax.tools.DiagnosticCollector;
import javax.tools.JavaCompiler;
import javax.tools.JavaCompiler.CompilationTask;
import javax.tools.JavaFileManager;
import javax.tools.JavaFileObject;
import javax.tools.ToolProvider;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.signature.qual.DotSeparatedIdentifiers;
import org.checkerframework.checker.signature.qual.FullyQualifiedName;
import org.checkerframework.framework.stub.AnnotationFileParser;
import org.checkerframework.framework.util.JavaParserUtil;
import org.plumelib.util.CollectionsPlume;
import org.plumelib.util.FilesPlume;

/** This program inserts annotations from an ajava file into a Java file. See {@link #main}. */
public class InsertAjavaAnnotations {
  /** Element utilities. */
  private final Elements elements;

  /**
   * Constructs an {@code InsertAjavaAnnotations} using the given {@code Elements} instance.
   *
   * @param elements an instance of {@code Elements}
   */
  public InsertAjavaAnnotations(Elements elements) {
    this.elements = elements;
  }

  /**
   * Gets an instance of {@code Elements} from the current Java compiler.
   *
   * @return the Element utilities
   */
  private static Elements createElements() {
    JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
    if (compiler == null) {
      System.err.println("Could not get compiler instance");
      System.exit(1);
    }

    DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<JavaFileObject>();
    try (JavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null, null)) {
      if (fileManager == null) {
        System.err.println("Could not get file manager");
        System.exit(1);
      }

      CompilationTask cTask =
          compiler.getTask(
              null,
              fileManager,
              diagnostics,
              Collections.emptyList(),
              null,
              Collections.emptyList());
      if (!(cTask instanceof JavacTask)) {
        System.err.println("Could not get a valid JavacTask: " + cTask.getClass());
        System.exit(1);
      }

      return ((JavacTask) cTask).getElements();
    } catch (IOException e) {
      throw new Error(e);
    }
  }

  /** Represents some text to be inserted at a file and its location. */
  private static class Insertion {
    /** Offset of the insertion in the file, measured in characters from the beginning. */
    public final int position;

    /** The contents of the insertion. */
    public final String contents;

    /** Whether the insertion should be on its own separate line. */
    public final boolean ownLine;

    /**
     * Constructs an insertion with the given position and contents.
     *
     * @param position offset of the insertion in the file
     * @param contents contents of the insertion
     */
    public Insertion(int position, String contents) {
      this(position, contents, false);
    }

    /**
     * Constructs an insertion with the given position and contents.
     *
     * @param position offset of the insertion in the file
     * @param contents contents of the insertion
     * @param ownLine true if this insertion should appear on its own separate line (doesn't affect
     *     the contents of the insertion)
     */
    public Insertion(int position, String contents, boolean ownLine) {
      this.position = position;
      this.contents = contents;
      this.ownLine = ownLine;
    }

    @Override
    public String toString() {
      return "Insertion [contents=" + contents + ", position=" + position + "]";
    }
  }

  /**
   * Given two JavaParser ASTs representing the same Java file but with differing annotations,
   * stores a list of {@link Insertion}s. The {@link Insertion}s represent how to textually modify
   * the file of the second AST to insert all annotations in the first AST into the second AST, but
   * this class doesn't modify the second AST itself. To use this class, call {@link
   * #visit(CompilationUnit, Node)} on a pair of ASTs and then use the contents of {@link
   * #insertions}.
   */
  private class BuildInsertionsVisitor extends DoubleJavaParserVisitor {
    /**
     * The set of annotations found in the file. Keys are both fully-qualified and simple names.
     * Contains an entry for the fully qualified name of each annotation and, if it was imported,
     * its simple name.
     *
     * <p>The map is populated from import statements and also when parsing a file that uses the
     * fully qualified name of an annotation it doesn't import.
     */
    private @MonotonicNonNull Map<String, TypeElement> allAnnotations = null;

    /** The annotation insertions seen so far. */
    public final List<Insertion> insertions = new ArrayList<>();

    /** A printer for annotations. */
    private final DefaultPrettyPrinter printer = new DefaultPrettyPrinter();

    /** The lines of the String representation of the second AST. */
    private final List<String> lines;

    /** The line separator used in the text the second AST was parsed from */
    private final String lineSeparator;

    /**
     * Stores the offsets of the lines in the string representation of the second AST. At index i,
     * stores the number of characters from the start of the file to the beginning of the ith line.
     */
    private final List<Integer> cumulativeLineSizes;

    /**
     * Constructs a {@code BuildInsertionsVisitor} where {@code destFileContents} is the String
     * representation of the AST to insert annotation into, that uses the given line separator. When
     * visiting a node pair, the second node must always be from an AST generated from this String.
     *
     * @param destFileContents the String the second vistide AST was parsed from
     * @param lineSeparator the line separator that {@code destFileContents} uses
     */
    public BuildInsertionsVisitor(String destFileContents, String lineSeparator) {
      allAnnotations = null;
      String[] lines = destFileContents.split(lineSeparator);
      this.lines = Arrays.asList(lines);
      this.lineSeparator = lineSeparator;
      cumulativeLineSizes = new ArrayList<>(lines.length);
      cumulativeLineSizes.add(0);
      for (int i = 1; i < lines.length; i++) {
        int lastSize = cumulativeLineSizes.get(i - 1);
        int lastLineLength = lines[i - 1].length() + lineSeparator.length();
        cumulativeLineSizes.add(lastSize + lastLineLength);
      }
    }

    @Override
    public void defaultAction(Node src, Node dest) {
      if (!(src instanceof NodeWithAnnotations<?>)) {
        return;
      }
      NodeWithAnnotations<?> srcWithAnnos = (NodeWithAnnotations<?>) src;

      // If `src` is a declaration, its annotations are declaration annotations.
      if (src instanceof MethodDeclaration) {
        addAnnotationOnOwnLine(dest.getBegin().get(), srcWithAnnos.getAnnotations());
        return;
      } else if (src instanceof FieldDeclaration) {
        addAnnotationOnOwnLine(dest.getBegin().get(), srcWithAnnos.getAnnotations());
        return;
      }

      // `src`'s annotations are type annotations.
      Position position;
      if (dest instanceof ClassOrInterfaceType) {
        // In a multi-part name like my.package.MyClass, type annotations go directly in
        // front of MyClass instead of the full name.
        position = ((ClassOrInterfaceType) dest).getName().getBegin().get();
      } else {
        position = dest.getBegin().get();
      }
      addAnnotations(position, srcWithAnnos.getAnnotations(), 0, false);
    }

    @Override
    public void visit(ArrayType src, Node other) {
      ArrayType dest = (ArrayType) other;
      Pair<Type, List<ArrayBracketPair>> destArrayTypes = ArrayType.unwrapArrayTypes(dest);
      TokenRange innerMostCom = destArrayTypes.a.getTokenRange().get();

      List<Position> positions = new ArrayList<>();
      for (JavaToken token : dest.getTokenRange().get().withBegin(innerMostCom.getEnd())) {
        if (token.getKind() == Kind.LBRACKET.getKind()) {
          positions.add(token.getRange().get().begin);
        }
      }

      // At the end of the loop, these two variables will contain the innermost array type.
      ArrayType srcArray = src;
      ArrayType destArray = dest;
      for (Position position : positions) {
        addAnnotations(position, srcArray.getAnnotations(), 0, true);
        if (srcArray.getComponentType().isArrayType()) {
          srcArray = (ArrayType) srcArray.getComponentType();
          destArray = (ArrayType) destArray.getComponentType();
        }
      }

      // Visit the innermost component type.
      srcArray.getComponentType().accept(this, destArray.getComponentType());
    }

    @Override
    @SuppressWarnings("optional:method.invocation") // parallel structure of two data structures
    public void visit(CompilationUnit src, Node other) {
      CompilationUnit dest = (CompilationUnit) other;
      defaultAction(src, dest);

      // Gather annotations used in the ajava file.
      allAnnotations = getImportedAnnotations(src);

      // Move any annotations that JavaParser puts in the declaration position but belong only
      // in the type position.
      src.accept(new TypeAnnotationMover(allAnnotations, elements), null);

      // Transfer import statements from the ajava file to the Java file.

      List<String> newImports;
      { // set `newImports`
        NodeList<ImportDeclaration> destImports = dest.getImports();
        Set<String> existingImports =
            new HashSet<>(CollectionsPlume.mapCapacity(destImports.size()));
        for (ImportDeclaration importDecl : destImports) {
          existingImports.add(printer.print(importDecl));
        }

        newImports = new ArrayList<>();
        for (ImportDeclaration importDecl : src.getImports()) {
          String importString = printer.print(importDecl);
          if (!existingImports.contains(importString)) {
            newImports.add(importString);
          }
        }
      }

      if (!newImports.isEmpty()) {
        int position;
        int lineBreaksBeforeFirstImport;
        if (!dest.getImports().isEmpty()) {
          Position lastImportPosition =
              dest.getImports().get(dest.getImports().size() - 1).getEnd().get();
          position = getFilePosition(lastImportPosition) + 1;
          lineBreaksBeforeFirstImport = 1;
        } else if (dest.getPackageDeclaration().isPresent()) {
          Position packagePosition = dest.getPackageDeclaration().get().getEnd().get();
          position = getFilePosition(packagePosition) + 1;
          lineBreaksBeforeFirstImport = 2;
        } else {
          position = 0;
          lineBreaksBeforeFirstImport = 0;
        }

        String insertionContent = "";
        // In Java 11, use String::repeat.
        for (int i = 0; i < lineBreaksBeforeFirstImport; i++) {
          insertionContent += lineSeparator;
        }
        insertionContent += String.join("", newImports);

        insertions.add(new Insertion(position, insertionContent));
      }

      src.getModule().ifPresent(m -> m.accept(this, dest.getModule().get()));
      src.getPackageDeclaration()
          .ifPresent(pd -> pd.accept(this, dest.getPackageDeclaration().get()));
      int numTypes = src.getTypes().size();
      for (int i = 0; i < numTypes; i++) {
        src.getTypes().get(i).accept(this, dest.getTypes().get(i));
      }
    }

    /**
     * Creates an insertion for a collection of annotations and adds it to {@link #insertions}. The
     * annotations will appear on their own line (unless any non-whitespace characters precede the
     * insertion position on its own line).
     *
     * @param position the position of the insertion
     * @param annotations list of annotations to insert
     */
    private void addAnnotationOnOwnLine(Position position, List<AnnotationExpr> annotations) {
      String line = lines.get(position.line - 1);
      int insertionColumn = position.column - 1;
      boolean ownLine = true;
      for (int i = 0; i < insertionColumn; i++) {
        if (line.charAt(i) != ' ' && line.charAt(i) != '\t') {
          ownLine = false;
          break;
        }
      }

      if (ownLine) {
        StringJoiner insertionContent = new StringJoiner(" ");
        for (AnnotationExpr annotation : annotations) {
          insertionContent.add(printer.print(annotation));
        }

        if (insertionContent.length() == 0) {
          return;
        }

        String leadingWhitespace = line.substring(0, insertionColumn);
        int filePosition = getFilePosition(position);
        insertions.add(
            new Insertion(
                filePosition,
                insertionContent.toString() + lineSeparator + leadingWhitespace,
                true));
      } else {
        addAnnotations(position, annotations, 0, false);
      }
    }

    /**
     * Creates an insertion for a collection of annotations at {@code position} + {@code offset} and
     * adds it to {@link #insertions}.
     *
     * @param position the position of the insertion
     * @param annotations list of annotations to insert
     * @param offset additional offset of the insertion after {@code position}
     * @param addSpaceBefore if true, the insertion content will start with a space
     */
    private void addAnnotations(
        Position position,
        Iterable<AnnotationExpr> annotations,
        int offset,
        boolean addSpaceBefore) {
      StringBuilder insertionContent = new StringBuilder();
      for (AnnotationExpr annotation : annotations) {
        insertionContent.append(printer.print(annotation));
        insertionContent.append(" ");
      }

      // Can't test `annotations.isEmpty()` earlier because `annotations` has type `Iterable`.
      if (insertionContent.length() == 0) {
        return;
      }

      if (addSpaceBefore) {
        insertionContent.insert(0, " ");
      }

      int filePosition = getFilePosition(position) + offset;
      insertions.add(new Insertion(filePosition, insertionContent.toString()));
    }

    /**
     * Converts a Position (which contains a line and column) to an offset from the start of the
     * file, in characters.
     *
     * @param position a Position
     * @return the total offset of the position from the start of the file
     */
    private int getFilePosition(Position position) {
      return cumulativeLineSizes.get(position.line - 1) + (position.column - 1);
    }
  }

  /**
   * Returns all annotations imported by the annotation file as a mapping from simple and qualified
   * names to TypeElements.
   *
   * @param cu compilation unit to extract imports from
   * @return a map from names to TypeElement, for all annotations imported by the annotation file.
   *     Two entries for each annotation: one for the simple name and another for the
   *     fully-qualified name, with the same value.
   */
  private Map<String, TypeElement> getImportedAnnotations(CompilationUnit cu) {
    if (cu.getImports() == null) {
      return Collections.emptyMap();
    }

    Map<String, TypeElement> result = new HashMap<>();
    for (ImportDeclaration importDecl : cu.getImports()) {
      if (importDecl.isAsterisk()) {
        @SuppressWarnings("signature" // https://tinyurl.com/cfissue/3094:
        // com.github.javaparser.ast.expr.Name inherits toString,
        // so there can be no annotation for it
        )
        @DotSeparatedIdentifiers String imported = importDecl.getName().toString();
        if (importDecl.isStatic()) {
          // Wildcard import of members of a type (class or interface)
          TypeElement element = elements.getTypeElement(imported);
          if (element != null) {
            // Find nested annotations
            result.putAll(AnnotationFileParser.annosInType(element));
          }

        } else {
          // Wildcard import of members of a package
          PackageElement element = elements.getPackageElement(imported);
          if (element != null) {
            result.putAll(AnnotationFileParser.annosInPackage(element));
          }
        }
      } else {
        @SuppressWarnings("signature" // importDecl is non-wildcard, so its name is
        // @FullyQualifiedName
        )
        @FullyQualifiedName String imported = importDecl.getNameAsString();
        TypeElement importType = elements.getTypeElement(imported);
        if (importType != null && importType.getKind() == ElementKind.ANNOTATION_TYPE) {
          TypeElement annoElt = elements.getTypeElement(imported);
          if (annoElt != null) {
            result.put(annoElt.getSimpleName().toString(), annoElt);
          }
        }
      }
    }
    return result;
  }

  /**
   * Inserts all annotations from the ajava file read from {@code annotationFile} into a Java file
   * with contents {@code javaFileContents} that uses the given line separator and returns the
   * resulting String.
   *
   * @param annotationFile input stream for an ajava file for {@code javaFileContents}
   * @param javaFileContents contents of a Java file to insert annotations into
   * @param lineSeparator the line separator {@code javaFileContents} uses
   * @return a modified {@code javaFileContents} with annotations from {@code annotationFile}
   *     inserted
   */
  public String insertAnnotations(
      InputStream annotationFile, String javaFileContents, String lineSeparator) {
    CompilationUnit annotationCu = JavaParserUtil.parseCompilationUnit(annotationFile);
    CompilationUnit javaCu = JavaParserUtil.parseCompilationUnit(javaFileContents);
    BuildInsertionsVisitor insertionVisitor =
        new BuildInsertionsVisitor(javaFileContents, lineSeparator);
    annotationCu.accept(insertionVisitor, javaCu);
    List<Insertion> insertions = insertionVisitor.insertions;
    insertions.sort(InsertAjavaAnnotations::compareInsertions);

    StringBuilder result = new StringBuilder(javaFileContents);
    for (Insertion insertion : insertions) {
      result.insert(insertion.position, insertion.contents);
    }
    return result.toString();
  }

  /**
   * Compares two insertions in the reverse order of where their content should appear in the file.
   * Making an insertion changes the offset values of all content after the insertion, so performing
   * the insertions in reverse order of appearance removes the need to recalculate the positions of
   * other insertions.
   *
   * <p>The order in which insertions should appear is determined first by their absolute position
   * in the file, and second by whether they have their own line. In a method like
   * {@code @Pure @Tainting String myMethod()} both annotations should be inserted at the same
   * location (right before "String"), but {@code @Pure} should always come first because it belongs
   * on its own line.
   *
   * @param insertion1 the first insertion
   * @param insertion2 the second insertion
   * @return a negative integer, zero, or a positive integer if {@code insertion1} belongs before,
   *     at the same position, or after {@code insertion2} respectively in the above ordering
   */
  private static int compareInsertions(Insertion insertion1, Insertion insertion2) {
    int cmp = Integer.compare(insertion1.position, insertion2.position);
    if (cmp == 0 && (insertion1.ownLine != insertion2.ownLine)) {
      if (insertion1.ownLine) {
        cmp = -1;
      } else {
        cmp = 1;
      }
    }

    return -cmp;
  }

  /**
   * Inserts all annotations from an ajava file into a Java file.
   *
   * @param annotationFileName an ajava file
   * @param javaFileName a Java file to insert annotation into
   */
  public void insertAnnotations(String annotationFileName, String javaFileName) {
    try {
      File javaFile = new File(javaFileName);
      String fileContents = FilesPlume.readString(Path.of(javaFileName));
      String lineSeparator = FilesPlume.inferLineSeparator(annotationFileName);
      try (FileInputStream annotationInputStream = new FileInputStream(annotationFileName)) {
        String result = insertAnnotations(annotationInputStream, fileContents, lineSeparator);
        FilesPlume.writeString(javaFile, result);
      }
    } catch (IOException e) {
      System.err.println(
          "Failed to insert annotations from file "
              + annotationFileName
              + " into file "
              + javaFileName);
      System.exit(1);
    }
  }

  /**
   * Inserts annotations from ajava files into Java files in place.
   *
   * <p>The first argument is an ajava file or a directory containing ajava files.
   *
   * <p>The second argument is a Java file or a directory containing Java files to insert
   * annotations into.
   *
   * <p>For each Java file, checks if any ajava files from the first argument match it. For each
   * such ajava file, inserts all its annotations into the Java file.
   *
   * @param args command line arguments: the first element should be a path to ajava files and the
   *     second should be the directory containing Java files to insert into
   */
  public static void main(String[] args) {
    if (args.length != 2) {
      System.out.println(
          "Usage: java InsertAjavaAnnotations <ajava-file-or-directory> <java-file-or-directory");
      System.exit(1);
    }

    String ajavaDir = args[0];
    String javaSourceDir = args[1];
    AnnotationFileStore annotationFiles = new AnnotationFileStore();
    annotationFiles.addFileOrDirectory(new File(ajavaDir));
    InsertAjavaAnnotations inserter = new InsertAjavaAnnotations(createElements());
    // For each Java file, this visitor inserts annotations into it.
    FileVisitor<Path> insertionVisitor =
        new SimpleFileVisitor<Path>() {
          @Override
          public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) {
            if (!path.getFileName().toString().endsWith(".java")) {
              return FileVisitResult.CONTINUE;
            }

            CompilationUnit root = null;
            try {
              root = JavaParserUtil.parseCompilationUnit(path.toFile());
            } catch (IOException e) {
              System.err.println("Failed to read file: " + path);
              System.exit(1);
            }

            List<TypeDeclaration<?>> rootTypes = root.getTypes();
            // Estimate of size.
            Set<String> annotationFilesForRoot =
                new LinkedHashSet<>(CollectionsPlume.mapCapacity(rootTypes.size()));
            for (TypeDeclaration<?> type : rootTypes) {
              String name = JavaParserUtil.getFullyQualifiedName(type, root);
              annotationFilesForRoot.addAll(annotationFiles.getAnnotationFileForType(name));
            }

            for (String annotationFile : annotationFilesForRoot) {
              inserter.insertAnnotations(annotationFile, path.toString());
            }

            return FileVisitResult.CONTINUE;
          }
        };

    try {
      Files.walkFileTree(Paths.get(javaSourceDir), insertionVisitor);
    } catch (IOException e) {
      System.out.println("Error while adding annotations to: " + javaSourceDir);
      e.printStackTrace();
      System.exit(1);
    }
  }
}
