package com.google.javascript.gents;

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

import com.google.common.collect.Sets;
import com.google.common.io.ByteSource;
import com.google.common.io.Files;
import com.google.javascript.jscomp.CodeConsumer;
import com.google.javascript.jscomp.CodeGenerator;
import com.google.javascript.jscomp.CodePrinter;
import com.google.javascript.jscomp.CodePrinter.Builder.CodeGeneratorFactory;
import com.google.javascript.jscomp.CodePrinter.Format;
import com.google.javascript.jscomp.Compiler;
import com.google.javascript.jscomp.CompilerOptions;
import com.google.javascript.jscomp.DiagnosticType;
import com.google.javascript.jscomp.ErrorFormat;
import com.google.javascript.jscomp.JSError;
import com.google.javascript.jscomp.SourceFile;
import com.google.javascript.rhino.Node;
import java.io.BufferedWriter;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.kohsuke.args4j.CmdLineException;

/**
 * A tool that transpiles {@code .js} ES6 and ES5 Closure annotated JavaScript to {@code .ts}
 * TypeScript.
 */
public class TypeScriptGenerator {

  /** Diagnostic that indicates Gents somehow produced an incorrect AST structure. */
  private static final DiagnosticType GENTS_INTERNAL_ERROR =
      DiagnosticType.error("CLUTZ_INTERNAL_ERROR", "Gents failed: {0}");

  /**
   * Command line clang-format string to format stdin. The filename 'a.ts' is only used to inform
   * clang-format of the file type (TS).
   */
  private static final String[] CLANG_FORMAT = {
    "./node_modules/.bin/clang-format", "-assume-filename=a.ts", "-style=Google"
  };

  static {
    // In some environments (Mac OS X programs started from Finder, like your IDE) PATH does
    // not contain "clang-format". This property allows explicitly configuring its location.
    String cfLocation = System.getProperty("gents.clangFormat");
    if (cfLocation != null) {
      CLANG_FORMAT[0] = cfLocation;
    }
  }

  public static void main(String[] args) {
    Options options = null;
    try {
      options = new Options(args);
    } catch (CmdLineException e) {
      System.err.println(e.getMessage());
      System.err.println("Usage: gents [options...] arguments...");
      e.getParser().printUsage(System.err);
      System.err.println();
      System.exit(1);
    }
    TypeScriptGenerator generator = null;
    try {
      generator = new TypeScriptGenerator(options);
      generator.generateTypeScript();
      if (generator.hasErrors()) {
        // Already reported through the print stream.
        System.exit(2);
      }
    } catch (Exception e) {
      e.printStackTrace(System.err);
      System.err.println("Uncaught exception in gents, exiting.");
      System.exit(3);
    }
    System.exit(0);
  }

  private final Options opts;
  private final Compiler compiler;

  final PathUtil pathUtil;
  private final NameUtil nameUtil;
  private GentsErrorManager errorManager;

  TypeScriptGenerator(Options opts) {
    this.opts = opts;
    this.compiler = new Compiler();
    compiler.disableThreads();
    setErrorStream(System.err);

    this.pathUtil = new PathUtil(opts.root, opts.absolutePathPrefix);
    this.nameUtil = new NameUtil(compiler);
  }

  void setErrorStream(PrintStream errStream) {
    errorManager =
        new GentsErrorManager(
            errStream, ErrorFormat.MULTILINE.toFormatter(compiler, true), opts.debug);
    compiler.setErrorManager(errorManager);
  }

  public boolean hasErrors() {
    return compiler.getErrorManager().getErrorCount() > 0;
  }

  private void generateTypeScript() {
    List<SourceFile> srcFiles = getFiles(opts.srcFiles);
    List<SourceFile> externFiles = getFiles(opts.externs);
    Set<String> filesToConvert = Sets.newLinkedHashSet(opts.filesToConvert);

    GentsResult result = generateTypeScript(filesToConvert, srcFiles, externFiles);
    Map<String, String> resultFileMap = result.sourceFileMap;

    for (String filename : filesToConvert) {
      String relativePath = pathUtil.getRelativePath(".", filename);
      String filepath = pathUtil.getFilePathWithoutExtension(relativePath);
      String tsCode = resultFileMap.get(filepath);
      if ("-".equals(opts.output)) {
        System.out.println("========================================");
        System.out.println("File: " + relativePath);
        System.out.println("========================================");
        System.out.println(tsCode);
      } else {
        String tsFilename = pathUtil.removeExtension(relativePath) + ".ts";
        File output = new File(new File(opts.output), tsFilename);
        if (!output.getParentFile().exists() && !output.getParentFile().mkdirs()) {
          throw new IllegalArgumentException("Unable to make directories " + output.getParent());
        }
        try {
          Files.asCharSink(output, UTF_8).write(tsCode);
        } catch (IOException e) {
          throw new IllegalArgumentException("Unable to write to file " + output.getName(), e);
        }
      }
    }
    try {
      if (opts.moduleRewriteLog != null) {
        Files.asCharSink(new File(opts.moduleRewriteLog), UTF_8).write(result.moduleRewriteLog);
      }
    } catch (IOException e) {
      throw new IllegalArgumentException("Unable to write to file " + opts.moduleRewriteLog, e);
    }
  }

  /** Returns a map from the basename to the TypeScript code generated for the file. */
  public GentsResult generateTypeScript(
      Set<String> filesToConvert, List<SourceFile> srcFiles, List<SourceFile> externs)
      throws AssertionError {
    GentsResult result = new GentsResult();

    final CompilerOptions compilerOpts = opts.getCompilerOptions();
    // Compile javascript code
    compiler.compile(externs, srcFiles, compilerOpts);

    Node externRoot = compiler.getRoot().getFirstChild();
    Node srcRoot = compiler.getRoot().getLastChild();

    new RemoveGoogScopePass(compiler).process(externRoot, srcRoot);

    CollectModuleMetadata modulePrePass =
        new CollectModuleMetadata(compiler, nameUtil, filesToConvert);
    modulePrePass.process(externRoot, srcRoot);

    // Strips all file nodes that we are not compiling.
    stripNonCompiledNodes(srcRoot, filesToConvert);

    CommentLinkingPass commentsPass = new CommentLinkingPass(compiler);
    commentsPass.process(externRoot, srcRoot);
    final NodeComments comments = commentsPass.getComments();

    ModuleConversionPass modulePass =
        new ModuleConversionPass(
            compiler,
            pathUtil,
            nameUtil,
            modulePrePass.getFileMap(),
            modulePrePass.getNamespaceMap(),
            comments,
            opts.alreadyConvertedPrefix);
    modulePass.process(externRoot, srcRoot);

    new TypeConversionPass(compiler, modulePrePass, comments).process(externRoot, srcRoot);

    new TypeAnnotationPass(
            compiler,
            pathUtil,
            nameUtil,
            modulePrePass.getSymbolMap(),
            modulePass.getTypeRewrite(),
            comments,
            opts.externsMap)
        .process(externRoot, srcRoot);

    new StyleFixPass(compiler, comments).process(externRoot, srcRoot);

    // We only use the source root as the extern root is ignored for codegen
    for (Node file : srcRoot.children()) {
      try {
        String filepath = pathUtil.getFilePathWithoutExtension(file.getSourceFileName());
        CodeGeneratorFactory factory =
            new CodeGeneratorFactory() {
              @Override
              public CodeGenerator getCodeGenerator(Format outputFormat, CodeConsumer cc) {
                return new GentsCodeGenerator(cc, compilerOpts, comments, opts.externsMap);
              }
            };

        String tsCode =
            new CodePrinter.Builder(file)
                .setCompilerOptions(opts.getCompilerOptions())
                .setTypeRegistry(compiler.getTypeRegistry())
                .setCodeGeneratorFactory(factory)
                .setPrettyPrint(true)
                .setLineBreak(true)
                .setOutputTypes(true)
                .build();

        // For whatever reason closure sometimes prefixes the emit with an empty new line. Strip
        // newlines not present in the original source.
        CharSequence originalSourceCode =
            compiler.getSourceFileContentByName(file.getSourceFileName());

        Integer originalCount = countBeginningNewlines(originalSourceCode);
        Integer newCount = countBeginningNewlines(tsCode);

        if (newCount > originalCount) {
          tsCode = tsCode.substring(newCount - originalCount);
        }

        result.sourceFileMap.put(filepath, tryClangFormat(tsCode));
      } catch (Throwable t) {
        System.err.println("Failed while converting " + file.getSourceFileName());
        t.printStackTrace(System.err);
        compiler.report(
            JSError.make(file.getSourceFileName(), -1, -1, GENTS_INTERNAL_ERROR, t.getMessage()));
      }
    }

    result.moduleRewriteLog =
        new ModuleRenameLogger()
            .generateModuleRewriteLog(filesToConvert, modulePrePass.getNamespaceMap());
    errorManager.doGenerateReport();
    return result;
  }

  private Integer countBeginningNewlines(CharSequence originalSourceCode) {
    Integer originalCount = 0;
    for (Integer i = 0; i < originalSourceCode.length(); i++) {
      // There's a terrible hack in GentsCodeGenerator that it sometimes adds " \n" instead of "\n".
      // Count and strip that too.
      if (originalSourceCode.charAt(i) == '\n'
          || (originalSourceCode.charAt(i) == ' '
              && i + 1 < originalSourceCode.length()
              && originalSourceCode.charAt(i + 1) == '\n')) {
        originalCount += 1;
      } else {
        break;
      }
    }
    return originalCount;
  }

  /**
   * Attempts to format the generated TypeScript using clang-format. On failure to format (i.e.
   * clang-format does not exist), return the inputed string.
   */
  private static String tryClangFormat(String code) {
    Process process = null;
    try {
      process = Runtime.getRuntime().exec(CLANG_FORMAT);
      final OutputStream stdin = process.getOutputStream();
      // stdout must be final for the nested object byteSource to return it.
      final InputStream stdout = process.getInputStream();

      // Write TypeScript code to stdin of the process
      try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(stdin, UTF_8)); ) {
        writer.write(code);
        writer.close();
      }
      return readStream(stdout);
    } catch (IOException e) {
      System.err.println("clang-format has failed to execute: " + e.getMessage());
      return code;
    } finally {
      if (process != null) {
        try {
          System.err.println(readStream(process.getErrorStream()));
        } catch (
            @SuppressWarnings("unused")
            IOException ignored) {
          // Ignored.
        }
        // TODO(renez): Use .waitFor(n, TimeUnit.SECONDS) and .destroyForcibly() once we moved to
        // Java 8.
        process.destroy();
      }
    }
  }

  private static String readStream(final InputStream stream) throws IOException {
    ByteSource byteSource =
        new ByteSource() {
          @Override
          public InputStream openStream() throws IOException {
            return stream;
          }
        };
    return byteSource.asCharSource(UTF_8).read();
  }

  /** Removes the root nodes for all the library files from the source node. */
  private static void stripNonCompiledNodes(Node n, Set<String> filesToCompile) {
    for (Node child : n.children()) {
      if (!filesToCompile.contains(child.getSourceFileName())) {
        child.detach();
      }
    }
  }

  /** Returns a list of source files from a list of file names. */
  private static List<SourceFile> getFiles(Collection<String> fileNames) {
    List<SourceFile> files = new ArrayList<>(fileNames.size());
    for (String fileName : fileNames) {
      files.add(SourceFile.fromFile(fileName, UTF_8));
    }
    return files;
  }

  static class GentsResult {

    public Map<String, String> sourceFileMap = new LinkedHashMap<>();
    public String moduleRewriteLog = "";
  }
}
