package com.google.javascript.gents;

import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.google.javascript.gents.pass.comments.GeneralComment;
import com.google.javascript.gents.pass.comments.NodeComments;
import com.google.javascript.jscomp.CodeConsumer;
import com.google.javascript.jscomp.CodeGenerator;
import com.google.javascript.jscomp.Compiler;
import com.google.javascript.jscomp.CompilerOptions;
import com.google.javascript.jscomp.NodeTraversal;
import com.google.javascript.rhino.JSDocInfo.Visibility;
import com.google.javascript.rhino.Node;
import com.google.javascript.rhino.Token;
import java.io.IOException;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.Nullable;

/** Code generator for gents to add TypeScript specific code generation. */
public class GentsCodeGenerator extends CodeGenerator {

  private final NodeComments astComments;
  private final NodeComments nodeComments;
  private final Map<String, String> externsMap;
  private final SourceExtractor extractor;
  private final Set<GeneralComment> astCommentsPresent;

  public GentsCodeGenerator(
      CodeConsumer consumer,
      CompilerOptions options,
      NodeComments astComments,
      NodeComments nodeComments,
      Map<String, String> externsMap,
      SourceExtractor extractor,
      Compiler compiler,
      Node root) {
    super(consumer, options);
    this.astComments = astComments;
    this.nodeComments = nodeComments;
    this.externsMap = externsMap;
    this.extractor = extractor;
    /*
     * Preference should be given to using a comment identified by the AstCommentLinkingPass
     * if that pass has associated the comment with any node in the AST.
     *
     * This is needed to support trailing comments because the CommentLinkingPass can
     * incorrectly assign a trailing comment to a node X that occurs earlier in the AST
     * from the node Y it should be associated with (and which the AstCommentLinkingPass has
     * associated it with).
     *
     * If precedence of comments is done on a per-node basis, then the comment will be
     * generated by node X before it could be correctly printed by Y.
     *
     * The set below records all of the comments identified by the AstCommentLinking
     * pass that are in the AST given by 'root'.
     *
     * Using this set, the comment will not be associated with node X because the
     * GentsCodeGenerator knows it was associated with another node (Y) by the
     * AstCommentLinkingPass.  As such, the comment will be associated with node Y which
     * can process it as a trailing comment after X has been processed.
     */
    this.astCommentsPresent = getAllCommentsWithin(astComments, compiler, root);
  }

  private static Set<GeneralComment> getAllCommentsWithin(
      NodeComments comments, Compiler compiler, Node root) {
    final Set<GeneralComment> allComments = Sets.newHashSet();
    NodeTraversal.traverse(
        compiler,
        root,
        new NodeTraversal.Callback() {
          @Override
          public boolean shouldTraverse(NodeTraversal t, Node n, Node parent) {
            return true;
          }

          @Override
          public void visit(NodeTraversal t, Node n, Node parent) {
            if (comments.hasComment(n)) {
              allComments.addAll(comments.getComments(n));
            }
          }
        });
    return allComments;
  }

  /**
   * Add new comments to the output for a particular node. Priority is given to the comments in
   * <code>astComments</code>, work is done to ensure comment duplication doesn't occur, and the
   * comments are added in order by their offset.
   *
   * @param astComments The comments determined by the AstCommentLinkingPass a particular node
   * @param linkedComments The comments determined by the CommentLinkingPass for the same node as
   *     <code>astComments</code>.
   */
  private void addNewComments(
      List<GeneralComment> astComments, List<GeneralComment> linkedComments) {
    if (astComments == null && linkedComments == null) {
      return;
    }

    List<GeneralComment> comments = Lists.newArrayList();
    if (astComments != null) {
      comments.addAll(astComments);
    }

    if (linkedComments != null) {
      for (GeneralComment c : linkedComments) {
        if (astCommentsPresent.contains(c)) {
          continue;
        }
        comments.add(c);
      }
    }

    comments.sort(Comparator.comparingInt(GeneralComment::getOffset));

    for (GeneralComment c : comments) {
      // CodeGernator.add("\n") doesn't append anything. Fixing the actual bug in Closure Compiler
      // is difficult. Works around the bug by passing " \n". The extra whitespace is stripped by
      // Closure and not emitted in the final output of Gents. An exception is when this is the
      // first line of file Closure doesn't strip the whitespace. TypeScriptGenerator has the
      // handling logic that removes leading empty lines, including "\n" and " \n".
      String text = c.getText();
      if (text.startsWith("/**")) {
        // Ensure JSDoc comments are on their own line.  When comments are linked to
        // Nodes, inline JSDoc comments are removed since they are needed in Closure
        // but are not needed in TypeScript.  Thus, we don't need to worry about
        // inline JSDoc comments here.
        add(" \n");
      }
      add(text);
      add(" \n");
    }
  }

  @Override
  protected void add(Node n, Context ctx) {
    @Nullable Node parent = n.getParent();
    maybeAddNewline(n);

    addNewComments(astComments.getComments(n), nodeComments.getComments(n));

    if (maybeOverrideCodeGen(n)) {
      return;
    }
    super.add(n, ctx);

    // Default field values
    switch (n.getToken()) {
      case NEW:
        // The Closure Compiler code generator drops off the extra () for new statements.
        // We add them back in to maintain a consistent style.
        if (n.hasOneChild()) {
          add("()");
        }
        break;
      case FUNCTION_TYPE:
        // Match the "(" in maybeOverrideCodeGen for FUNCTION_TYPE nodes.
        if (parent != null && parent.getToken() == Token.UNION_TYPE) {
          add(")");
        }
        break;
      default:
        break;
    }
  }

  private static final ImmutableSet<Token> TOKENS_TO_ADD_NEWLINES_BEFORE =
      ImmutableSet.of(
          Token.CLASS, Token.EXPORT, Token.FUNCTION, Token.INTERFACE, Token.MEMBER_FUNCTION_DEF);

  /** Add newlines to the generated source. */
  private void maybeAddNewline(Node n) {
    boolean hasComment =
        nodeComments.hasComment(n)
            || nodeComments.hasComment(n.getParent())
            || isPreviousEmptyAndHasComment(n)
            || (n.getParent() != null && isPreviousEmptyAndHasComment(n.getParent()));

    if (!hasComment && TOKENS_TO_ADD_NEWLINES_BEFORE.contains(n.getToken())) {
      // CodeGernator.add("\n") doesn't append anything. Fixing the actual bug in Closure Compiler
      // is difficult. Works around the bug by passing " \n". The extra whitespace is stripped by
      // Closure and not emitted in the final output of Gents. An exception is when this is the
      // first line of file Closure doesn't strip the whitespace. TypeScriptGenerator has the
      // handling logic that removes leading empty lines, including "\n" and " \n".
      add(" \n");
    }
  }

  private boolean isPreviousEmptyAndHasComment(Node n) {
    if (n == null || n.getParent() == null) {
      return false;
    }
    Node prev = n.getPrevious();
    return prev != null && prev.isEmpty() && nodeComments.hasComment(prev);
  }

  /**
   * Attempts to seize control of code generation if necessary.
   *
   * @return true if no further code generation on this node is needed.
   */
  private boolean maybeOverrideCodeGen(Node n) {
    @Nullable Node parent = n.getParent();
    switch (n.getToken()) {
      case CLASS:
        // Add "abstract" if it's relevant and then let JSComp take over.
        addAbstractKeyword(n);
        return false;
      case IF:
        // If the body of a conditional is written without a block, Rhino will wrap the body in
        // a synthetic block. In the case that "else" body is an "if" statement that was implictly
        // wrapped this way, it is nicer to flatten the "if" body to its original "else if" form.
        //   if (0) {} else if (1) {} -> if (0) {} else {* if(1) {} *} -> if (0) else if (1) {}
        //                                              ^^          ^^ -- synthetic block
        boolean hasElse = n.getChildCount() == 3;
        if (!hasElse ||
            // If `isAddedBlock` is true, then the node is a synthetic block.
            !n.getLastChild().isAddedBlock() ||
            !n.getLastChild().getFirstChild().isIf()) {
          return false;
        }

        add("if (");
        add(n.getFirstChild());
        add(")");
        add(n.getSecondChild());
        add("else");
        add(n.getLastChild().getFirstChild());
        return true;
      case INDEX_SIGNATURE:
        Node first = n.getFirstChild();
        if (null != first) {
          add("{[");
          add(first);
          add(":");
          add(first.getDeclaredTypeExpression());
          add("]:");
          add(n.getDeclaredTypeExpression());
          add("}");
        }
        return true;
      case UNDEFINED_TYPE:
        add("undefined");
        return true;
      case CAST:
        add("(");
        add(n.getFirstChild());
        add(" as ");
        add(n.getDeclaredTypeExpression());
        add(")");
        return true;
      case NUMBER:
      case STRING:
        try {
          String src = extractor.getSource(n);
          // Do not use the literal text for goog.require statements
          // because those statements should be replaced with import
          // statements.
          if (src != null && !src.contains("goog.require")) {
            add(src);
            return true;
          }
          return false;
        } catch (IOException e) {
          // there was a problem reading the source file
          // so have the generator use the default emit
          return false;
        }
      case STRING_KEY:
        // Taking over the string key emit so that we can emit optional properties like foo?,
        // without closure quoting the whole key as 'foo?'.
        if (!n.getString().contains("?")) return false;
        if (n.isShorthandProperty()) {
          return false;
        }
        add(n.getString());
        if (n.hasOneChild()) {
          add(": ");
          add(n.getFirstChild());
        }
        return true;
      case DEFAULT_VALUE:
      case NAME:
        // Prepend access modifiers on constructor params
        if (n.getParent().isParamList()) {
          // visibility must come before readonly.
          addVisibility(n);
          if (n.getBooleanProp(Node.IS_CONSTANT_NAME)) {
            add("readonly ");
          }
        }
        return false;
      case MEMBER_VARIABLE_DEF:
        // The Closure code generator does not emit the 'readonly' keyword.
        // Moreover, TypeScript requires that keywords are in the following order:
        // [public/private/protected] then "static" then "readonly".
        // This forces us to take over the whole emit of MEMBER_VARIABLE_DEF
        // to insert 'readonly' in the correct place.

        addVisibility(n);
        if (n.getBooleanProp(Node.STATIC_MEMBER)) {
          add("static ");
        }

        if (n.getBooleanProp(Node.IS_CONSTANT_NAME)) {
          add("readonly ");
        }

        add(n.getString());

        boolean hasInitializer = n.hasChildren();

        if (n.getDeclaredTypeExpression() != null) {
          Node type = n.getDeclaredTypeExpression();
          // If this member has an initializer, the TS typechecker will likely be able to infer a
          // narrower type than `any`, so let's elide the explicit `any` here.
          // Note that we *will* keep non-`any` type annotations, as these are intentional can make
          // a difference TS type resolution. For example, in
          //   const x: string = 'hi';
          // The type of `x` is `string`, but if the type annotation is removed, the type of `x`
          // becomes `'hi'`.
          if (!(type.getToken() == Token.ANY_TYPE && hasInitializer)) {
            add(":");
            add(n.getDeclaredTypeExpression());
          }
        }

        if (hasInitializer) {
          add(" = ");
          add(n.getLastChild());
        }
        return true;
      case ANY_TYPE:
        // Check the externsMap for an alias to use in place of "any"
        String anyTypeName = externsMap.get("any");
        if (anyTypeName != null) {
          add(anyTypeName);
          return true;
        }
        return false;
      case EXPORT:
        // When a type alias is exported, closure code generator will add two semi-colons, one for
        // type alias and one for export
        // For example: export type T = {key: string};;
        if (!n.hasOneChild()) {
          return false;
        }
        if (n.getFirstChild().getToken() == Token.TYPE_ALIAS) {
          add("export");
          add(n.getFirstChild());
          return true;
        }
        return false;
      case FUNCTION_TYPE:
        // In some cases we need to add a pair of "(" and ")" around the function type. We don't
        // want to override the default code generation for FUNCTION_TYPE because the default code
        // generation uses private APIs. Therefore we emit a "(" here, then let the default code
        // generation for FUNCTION_TYPE emit and finally emit a ")" after maybeOverrideCodeGen.
        // Union binding has higher precedence than "=>" in TypeScript.
        if (parent != null && parent.getToken() == Token.UNION_TYPE) {
          add("(");
        }
        return false;
      case MEMBER_FUNCTION_DEF:
        if (isAbstractNode(n)) {
          addAbstractKeyword(n);
          // We need to take over the emit here to skip emitting the BLOCK
          // because in TS abstract methods cannot have bodies.
          // The ast looks like:
          // MEMBER_FUNCTION_DEF
          //        FUNCTION
          //            NAME (GENERICS)?
          //            PARAM_LIST
          //            BLOCK
          add(n.getString());
          Node function = n.getFirstChild();
          // If this member function has generics, they were added on the function name during the type annotation pass.
          Node name = function.getFirstChild();
          if (name.getProp(Node.GENERIC_TYPE_LIST) != null) {
            add((Node) name.getProp(Node.GENERIC_TYPE_LIST)); // GENERICS
          }
          add(function.getSecondChild()); // PARAM_LIST
          add(":");
          if (function.getDeclaredTypeExpression() != null) {
            add(function.getDeclaredTypeExpression());
          } else {
            // Because in TS there is no body of abstract methods, the return type
            // of method is always required.
            add("void");
          }
          add(";");
          return true;
        }
        return false;
      default:
        return false;
    }
  }

  void addVisibility(Node n) {
    Visibility visibility = (Visibility) n.getProp(Node.ACCESS_MODIFIER);
    if (visibility != null) {
      switch (visibility) {
        case PRIVATE:
          add("private ");
          break;
        case PROTECTED:
          add("protected ");
          break;
        case PUBLIC:
          add("public ");
          break;
        default:
          break;
      }
    }
  }

  boolean isAbstractNode(Node n) {
    // This is a hack, we should be using a property like Node.IS_ABSTRACT
    // but it doesn't exist. So I just picked a random boolean property
    // that is not likely to be used on a class or member-function-def.
    return n.getBooleanProp(Node.WAS_PREVIOUSLY_PROVIDED);
  }

  void addAbstractKeyword(Node n) {
    // This is a hack, we should be using a property like Node.IS_ABSTRACT
    // but it doesn't exist. So I just picked a random boolean property
    // that is not likely to be used on a class or member-function-def.
    if (n.getBooleanProp(Node.WAS_PREVIOUSLY_PROVIDED)) {
      add("abstract ");
    }
  }
}