/*
 * Copyright 2016-2017 Spotify AB
 *
 * 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 com.spotify.format;

import static java.nio.charset.StandardCharsets.UTF_8;

import com.google.auto.value.AutoValue;
import com.google.common.collect.ImmutableList;
import com.google.common.hash.HashCode;
import com.google.common.hash.Hashing;
import com.google.common.io.CharStreams;
import com.google.common.io.MoreFiles;
import com.google.errorprone.annotations.MustBeClosed;
import com.google.googlejavaformat.java.Formatter;
import com.google.googlejavaformat.java.FormatterException;
import com.google.googlejavaformat.java.JavaFormatterOptions;
import com.spotify.bazeltools.cliutils.Cli;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.StringWriter;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.util.Arrays;
import java.util.Set;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.stream.Stream;
import joptsimple.AbstractOptionSpec;
import joptsimple.OptionParser;
import joptsimple.OptionSet;
import joptsimple.OptionSpec;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public final class Main {
  private static final Logger LOG = LoggerFactory.getLogger(Main.class);

  private Main() {}

  public static void main(String[] args) throws IOException {
    final OptionParser optionParser = new OptionParser();

    final OptionSpec<File> workspaceArgument =
        optionParser.accepts("workspace-directory").withRequiredArg().ofType(File.class);
    final OptionSpec<File> buildifierArgument =
        optionParser.accepts("buildifier").withRequiredArg().ofType(File.class);
    final OptionSpec<Void> verifyFlag = optionParser.accepts("verify");
    final OptionSpec<Void> gitChanges = optionParser.accepts("git-changes");

    final AbstractOptionSpec<Void> helpFlag = optionParser.accepts("help").forHelp();
    final AbstractOptionSpec<Void> verboseFlag =
        optionParser.acceptsAll(Arrays.asList("verbose", "v"));

    final OptionSet optionSet = optionParser.parse(args);

    Cli.configureLogging("format", optionSet.has(verboseFlag));

    if (optionSet.has(helpFlag)) {
      optionParser.printHelpOn(System.err);
      System.exit(0);
      return;
    }

    final Path workspaceDirectory;
    if (optionSet.has(workspaceArgument)) {
      workspaceDirectory = optionSet.valueOf(workspaceArgument).toPath();
    } else {
      LOG.error("Mandatory flag --workspace-directory missing (see --help)");
      System.exit(1);
      return;
    }

    final Path buildifier;
    if (optionSet.has(buildifierArgument)) {
      buildifier = optionSet.valueOf(buildifierArgument).toPath();
    } else {
      LOG.error("Mandatory flag --buildifier missing (see --help)");
      System.exit(1);
      return;
    }

    try {
      run(workspaceDirectory, buildifier, optionSet.has(verifyFlag), optionSet.has(gitChanges));
    } catch (final Exception e) {
      LOG.error("A fatal error occurred", e);
      System.exit(1);
    }
  }

  private static void run(
      final Path workspaceDirectory,
      final Path buildifier,
      final boolean verify,
      final boolean gitChanges)
      throws IOException {

    final JavaFormatterOptions options =
        JavaFormatterOptions.builder().style(JavaFormatterOptions.Style.GOOGLE).build();

    final Formatter formatter = new Formatter(options);
    final Set<Path> malformattedPaths = new ConcurrentSkipListSet<>();

    final Sources sources;
    if (gitChanges) {
      final ImmutableList<Path> paths = listGitChanges(workspaceDirectory);
      sources = new GitChangesSources(paths);
    } else {
      sources = new AllSources();
    }

    LOG.info("Processing Java files...");
    try (final Stream<Path> javaFiles =
        sources.findFilesMatching(workspaceDirectory, "glob:**/*" + ".java")) {
      javaFiles
          .parallel()
          .forEach(
              javaFile ->
                  handleResult(formatJavaFile(formatter, javaFile), verify, malformattedPaths));
    }

    LOG.info("Processing Scala files...");
    try (final Stream<Path> scalaFiles =
        sources.findFilesMatching(workspaceDirectory, "glob:**/*.scala")) {
      scalaFiles
          .parallel()
          .forEach(
              scalaFile -> handleResult(formatScalaFile(scalaFile), verify, malformattedPaths));
    }

    LOG.info("Processing Bazel BUILD files...");
    try (final Stream<Path> buildFiles =
        sources.findFilesMatching(workspaceDirectory, "glob:**/BUILD")) {
      buildFiles
          .parallel()
          .forEach(
              buildFile ->
                  handleResult(formatBuildFile(buildifier, buildFile), verify, malformattedPaths));
    }

    LOG.info("Processing Bazel WORKSPACE files...");
    try (final Stream<Path> workspaceFiles =
        sources.findFilesMatching(workspaceDirectory, "glob:**/WORKSPACE")) {
      workspaceFiles
          .parallel()
          .forEach(
              workspaceFile ->
                  handleResult(
                      formatBuildFile(buildifier, workspaceFile), verify, malformattedPaths));
    }

    if (!malformattedPaths.isEmpty()) {
      LOG.error("There are malformatted files, please run 'tools/format/run'!");
      LOG.error("Malformatted file paths are:");
      for (final Path malformattedPath : malformattedPaths) {
        LOG.error("  - {}", workspaceDirectory.relativize(malformattedPath));
      }
      System.exit(1);
    }
  }

  private static void handleResult(
      final FormattingResult formattingResult,
      final boolean verify,
      final Set<Path> malformedPaths) {
    // Use hashing to avoid loading the file into memory... We should probably also do this for
    // FormattingResult to be fair.
    final HashCode oldHash;
    try {
      oldHash = MoreFiles.asByteSource(formattingResult.path()).hash(Hashing.sha256());
    } catch (final IOException e) {
      throw new UncheckedIOException("Could not hash contents of " + formattingResult.path(), e);
    }
    final HashCode newHash =
        Hashing.sha256().hashBytes(formattingResult.contents().getBytes(StandardCharsets.UTF_8));
    if (!oldHash.equals(newHash)) {
      if (verify) {
        malformedPaths.add(formattingResult.path());
      } else {
        try {
          Files.write(formattingResult.path(), formattingResult.contents().getBytes(UTF_8));
        } catch (final IOException e) {
          throw new UncheckedIOException("Could not write file " + formattingResult.path(), e);
        }
      }
    }
  }

  private static FormattingResult formatBuildFile(final Path buildifier, final Path buildFile) {
    final Process process;
    try {
      process =
          new ProcessBuilder()
              .command(buildifier.toString())
              .redirectInput(buildFile.toFile())
              .redirectOutput(ProcessBuilder.Redirect.PIPE)
              .redirectError(ProcessBuilder.Redirect.INHERIT)
              .start();
    } catch (IOException e) {
      throw new UncheckedIOException("Unable to run buildifier on " + buildFile, e);
    }

    final StringWriter writer = new StringWriter();
    try (final InputStreamReader reader = new InputStreamReader(process.getInputStream(), UTF_8)) {
      CharStreams.copy(reader, writer);
    } catch (IOException e) {
      throw new UncheckedIOException("Unable to read buildifier output for " + buildFile, e);
    }

    final int exitCode;
    try {
      exitCode = process.waitFor();
    } catch (final InterruptedException e) {
      throw new IllegalStateException("Interrupted while formatting file " + buildFile, e);
    }

    if (exitCode != 0) {
      throw new IllegalStateException("Unable to format file " + buildFile);
    }

    return FormattingResult.create(buildFile, writer.toString());
  }

  private static FormattingResult formatJavaFile(final Formatter formatter, final Path javaFile) {
    return FormattingResult.create(
        javaFile, formatJavaSource(formatter, javaFile, readFile(javaFile)));
  }

  private static FormattingResult formatScalaFile(final Path scalaFile) {
    return FormattingResult.create(scalaFile, formatScalaSource(scalaFile, readFile(scalaFile)));
  }

  private static String formatJavaSource(
      final Formatter formatter, final Path javaFile, final String source) {
    final String formattedSource;
    try {
      formattedSource = formatter.formatSource(source);
    } catch (final FormatterException e) {
      throw new IllegalStateException("Could not format source in file " + javaFile, e);
    }
    return formattedSource;
  }

  private static String formatScalaSource(final Path scalaFile, final String source) {
    final String formattedSource;
    try {
      formattedSource = ScalaFmt.format(source);
    } catch (final Exception e) {
      throw new IllegalStateException("Could not format source in file " + scalaFile, e);
    }
    return formattedSource;
  }

  private static ImmutableList<Path> listGitChanges(final Path root) {
    final Process process;
    try {
      process =
          new ProcessBuilder()
              .directory(root.toFile())
              .command("git", "status", "--porcelain", "--no-renames")
              .redirectOutput(ProcessBuilder.Redirect.PIPE)
              .redirectError(ProcessBuilder.Redirect.INHERIT)
              .start();
    } catch (IOException e) {
      throw new UncheckedIOException("Unable to run git chagnes", e);
    }

    final int exitCode;
    try {
      exitCode = process.waitFor();
    } catch (final InterruptedException e) {
      throw new IllegalStateException("Interrupted while git changes", e);
    }

    if (exitCode != 0) {
      throw new IllegalStateException("Unable list git changes exit code: " + exitCode);
    }

    final ImmutableList.Builder<Path> changes = ImmutableList.builder();
    try (final BufferedReader reader =
        new BufferedReader(new InputStreamReader(process.getInputStream(), UTF_8))) {
      while (reader.ready()) {
        final String line = reader.readLine().trim();

        if (!line.isEmpty()) {
          final String relativePath = line.substring(line.indexOf(' ') + 1);
          changes.add(root.resolve(relativePath).toAbsolutePath());
        }
      }
    } catch (IOException e) {
      throw new UncheckedIOException("Unable to read git output", e);
    }

    return changes.build();
  }

  private static String readFile(final Path javaFile) {
    final String source;
    try {
      source = new String(Files.readAllBytes(javaFile), UTF_8);
    } catch (final IOException e) {
      throw new UncheckedIOException("Could not read file " + javaFile, e);
    }
    return source;
  }

  private interface Sources {
    @MustBeClosed
    Stream<Path> findFilesMatching(Path directory, String syntaxAndPattern) throws IOException;
  }

  private static class AllSources implements Sources {
    @MustBeClosed
    @Override
    public Stream<Path> findFilesMatching(final Path directory, final String syntaxAndPattern)
        throws IOException {
      final PathMatcher matcher = directory.getFileSystem().getPathMatcher(syntaxAndPattern);
      return Files.find(
          directory, Integer.MAX_VALUE, (p, a) -> matcher.matches(p) && !a.isDirectory());
    }
  }

  private static class GitChangesSources implements Sources {
    private final ImmutableList<Path> sources;

    private GitChangesSources(final ImmutableList<Path> sources) {
      this.sources = sources;
    }

    @MustBeClosed
    @Override
    public Stream<Path> findFilesMatching(final Path directory, final String syntaxAndPattern) {
      final PathMatcher matcher = directory.getFileSystem().getPathMatcher(syntaxAndPattern);
      return sources.stream().filter(matcher::matches);
    }
  }

  @AutoValue
  abstract static class FormattingResult {

    abstract Path path();

    abstract String contents();

    static FormattingResult create(final Path path, final String contents) {
      return new AutoValue_Main_FormattingResult(path, contents);
    }
  }
}
