/*
 * Copyright 2007 The Closure Compiler Authors.
 *
 * 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.google.javascript.jscomp;

import javax.annotation.Nullable;
import com.google.common.collect.Sets;
import com.google.javascript.jscomp.CodingConvention.SubclassRelationship;
import com.google.javascript.jscomp.NodeTraversal.AbstractPostOrderCallback;
import com.google.javascript.rhino.IR;
import com.google.javascript.rhino.Node;
import com.google.javascript.rhino.Token;

import java.util.Set;

/**
 * A pass for stripping a list of provided JavaScript object types.
 *
 * The stripping strategy is as follows:
 *   - Provide: 1) a list of types that should be stripped, and 2) a list of
 *     suffixes of field/variable names that should be stripped.
 *   - Remove declarations of variables that are initialized using static
 *     methods of strip types (e.g. var x = goog.debug.Logger.getLogger(...);).
 *   - Remove all references to variables that are stripped.
 *   - Remove all object literal keys with strip names.
 *   - Remove all assignments to 1) field names that are strip names and
 *     2) qualified names that begin with strip types.
 *   - Remove all statements containing calls to static methods of strip types.
 *
 */
class StripCode implements CompilerPass {

  // TODO(user): Try eliminating the need for a list of strip names by instead
  // recording which field names are assigned to debug types in each JS input.
  private final AbstractCompiler compiler;
  private final Set<String> stripTypes;
  private final Set<String> stripNameSuffixes;
  private final Set<String> stripTypePrefixes;
  private final Set<String> stripNamePrefixes;
  private final Set<Scope.Var> varsToRemove;

  static final DiagnosticType STRIP_TYPE_INHERIT_ERROR = DiagnosticType.error(
      "JSC_STRIP_TYPE_INHERIT_ERROR",
      "Non-strip type {0} cannot inherit from strip type {1}");

  static final DiagnosticType STRIP_ASSIGNMENT_ERROR = DiagnosticType.error(
      "JSC_STRIP_ASSIGNMENT_ERROR",
      "Unable to strip assignment to {0}");

  /**
   * Creates an instance.
   *
   * @param compiler The compiler
   */
  StripCode(AbstractCompiler compiler,
            Set<String> stripTypes,
            Set<String> stripNameSuffixes,
            Set<String> stripTypePrefixes,
            Set<String> stripNamePrefixes) {

    this.compiler = compiler;
    this.stripTypes = Sets.newHashSet(stripTypes);
    this.stripNameSuffixes = Sets.newHashSet(stripNameSuffixes);
    this.stripTypePrefixes = Sets.newHashSet(stripTypePrefixes);
    this.stripNamePrefixes = Sets.newHashSet(stripNamePrefixes);
    this.varsToRemove = Sets.newHashSet();
  }

  /**
   * Enables stripping of goog.tweak functions.
   */
  public void enableTweakStripping() {
    stripTypes.add("goog.tweak");
  }

  @Override
  public void process(Node externs, Node root) {
    NodeTraversal.traverse(compiler, root, new Strip());
  }

  // -------------------------------------------------------------------------

  /**
   * A callback that strips debug code from a JavaScript parse tree.
   */
  private class Strip extends AbstractPostOrderCallback {

    @Override
    public void visit(NodeTraversal t, Node n, Node parent) {
      switch (n.getType()) {
        case Token.VAR:
          removeVarDeclarationsByNameOrRvalue(t, n, parent);
          break;

        case Token.NAME:
          maybeRemoveReferenceToRemovedVariable(t, n, parent);
          break;

        case Token.ASSIGN:
        case Token.ASSIGN_BITOR:
        case Token.ASSIGN_BITXOR:
        case Token.ASSIGN_BITAND:
        case Token.ASSIGN_LSH:
        case Token.ASSIGN_RSH:
        case Token.ASSIGN_URSH:
        case Token.ASSIGN_ADD:
        case Token.ASSIGN_SUB:
        case Token.ASSIGN_MUL:
        case Token.ASSIGN_DIV:
        case Token.ASSIGN_MOD:
          maybeEliminateAssignmentByLvalueName(t, n, parent);
          break;

        case Token.CALL:
        case Token.NEW:
          maybeRemoveCall(t, n, parent);
          break;

        case Token.OBJECTLIT:
          eliminateKeysWithStripNamesFromObjLit(t, n);
          break;

        case Token.EXPR_RESULT:
          maybeEliminateExpressionByName(t, n, parent);
          break;
      }
    }

    /**
     * Removes declarations of any variables whose names are strip names or
     * whose whose r-values are static method calls on strip types. Builds a set
     * of removed variables so that all references to them can be removed.
     *
     * @param t The traversal
     * @param n A VAR node
     * @param parent {@code n}'s parent
     */
    void removeVarDeclarationsByNameOrRvalue(NodeTraversal t, Node n,
        Node parent) {
      for (Node nameNode = n.getFirstChild(); nameNode != null;
          nameNode = nameNode.getNext()) {
        String name = nameNode.getString();
        if (isStripName(name) ||
            isCallWhoseReturnValueShouldBeStripped(nameNode.getFirstChild())) {
          // Remove the NAME.
          Scope scope = t.getScope();
          varsToRemove.add(scope.getVar(name));
          n.removeChild(nameNode);
          compiler.reportCodeChange();
        }
      }
      if (!n.hasChildren()) {
        // Must also remove the VAR.
        replaceWithEmpty(n, parent);
        compiler.reportCodeChange();
      }
    }

    /**
     * Removes a reference if it is a reference to a removed variable.
     *
     * @param t The traversal
     * @param n A NAME node
     * @param parent {@code n}'s parent
     */
    void maybeRemoveReferenceToRemovedVariable(NodeTraversal t, Node n,
                                               Node parent) {
      switch (parent.getType()) {
        case Token.VAR:
          // This is a variable declaration, not a reference.
          break;

        case Token.GETPROP:
          // GETPROP
          //   NAME
          //   STRING (property name)
        case Token.GETELEM:
          // GETELEM
          //   NAME
          //   NUMBER|STRING|NAME|...
          if (parent.getFirstChild() == n && isReferenceToRemovedVar(t, n)) {
            replaceHighestNestedCallWithNull(parent, parent.getParent());
          }
          break;

        case Token.ASSIGN:
        case Token.ASSIGN_BITOR:
        case Token.ASSIGN_BITXOR:
        case Token.ASSIGN_BITAND:
        case Token.ASSIGN_LSH:
        case Token.ASSIGN_RSH:
        case Token.ASSIGN_URSH:
        case Token.ASSIGN_ADD:
        case Token.ASSIGN_SUB:
        case Token.ASSIGN_MUL:
        case Token.ASSIGN_DIV:
        case Token.ASSIGN_MOD:
          if (isReferenceToRemovedVar(t, n)) {
            if (parent.getFirstChild() == n) {
              Node gramps = parent.getParent();
              if (gramps.isExprResult()) {
                // Remove the assignment.
                Node greatGramps = gramps.getParent();
                replaceWithEmpty(gramps, greatGramps);
                compiler.reportCodeChange();
              } else {
                // Substitute the r-value for the assignment.
                Node rvalue = n.getNext();
                parent.removeChild(rvalue);
                gramps.replaceChild(parent, rvalue);
                compiler.reportCodeChange();
              }
            } else {
              // The var reference is the r-value. Replace it with null.
              replaceWithNull(n, parent);
              compiler.reportCodeChange();
            }
          }
          break;

        default:
          if (isReferenceToRemovedVar(t, n)) {
            replaceWithNull(n, parent);
            compiler.reportCodeChange();
          }
          break;
      }
    }

    /**
     * Use a while loop to get up out of any nested calls. For example,
     * if we have just detected that we need to remove the a.b() call
     * in a.b().c().d(), we'll have to remove all of the calls, and it
     * will take a few iterations through this loop to get up to d().
     */
    void replaceHighestNestedCallWithNull(Node node, Node parent) {
      Node ancestor = parent;
      Node ancestorChild = node;
      while (true) {
        if (ancestor.getFirstChild() != ancestorChild) {
          replaceWithNull(ancestorChild, ancestor);
          break;
        }
        if (ancestor.isExprResult()) {
          // Remove the entire expression statement.
          Node ancParent = ancestor.getParent();
          replaceWithEmpty(ancestor, ancParent);
          break;
        }
        int type = ancestor.getType();
        if (type != Token.GETPROP &&
            type != Token.GETELEM &&
            type != Token.CALL) {
          replaceWithNull(ancestorChild, ancestor);
          break;
        }
        ancestorChild = ancestor;
        ancestor = ancestor.getParent();
      }
      compiler.reportCodeChange();
    }

    /**
     * Eliminates an assignment if the l-value is:
     *  - A field name that's a strip name
     *  - A qualified name that begins with a strip type
     *
     * @param t The traversal
     * @param n An ASSIGN node
     * @param parent {@code n}'s parent
     */
    void maybeEliminateAssignmentByLvalueName(NodeTraversal t, Node n,
                                              Node parent) {
      // ASSIGN
      //   l-value
      //   r-value
      Node lvalue = n.getFirstChild();
      if (nameEndsWithFieldNameToStrip(lvalue) ||
          qualifiedNameBeginsWithStripType(lvalue)) {

        // Limit to EXPR_RESULT because it is not
        // safe to eliminate assignment in complex expressions,
        // e.g. in ((x = 7) + 8)
        if (parent.isExprResult()) {
          Node gramps = parent.getParent();
          replaceWithEmpty(parent, gramps);
          compiler.reportCodeChange();
        } else {
          t.report(n, STRIP_ASSIGNMENT_ERROR, lvalue.getQualifiedName());
        }
      }
    }

    /**
     * Eliminates an expression if it refers to:
     *  - A field name that's a strip name
     *  - A qualified name that begins with a strip type
     * This gets rid of construct like:
     *  a.prototype.logger; (used instead of a.prototype.logger = null;)
     * This expression is not an assignment and so will not be caught by
     * maybeEliminateAssignmentByLvalueName.
     * @param t The traversal
     * @param n An EXPR_RESULT node
     * @param parent {@code n}'s parent
     */
    void maybeEliminateExpressionByName(NodeTraversal t, Node n,
                                        Node parent) {
      // EXPR_RESULT
      //   expression
      Node expression = n.getFirstChild();
      if (nameEndsWithFieldNameToStrip(expression) ||
          qualifiedNameBeginsWithStripType(expression)) {
        if (parent.isExprResult()) {
          Node gramps = parent.getParent();
          replaceWithEmpty(parent, gramps);
        } else {
          replaceWithEmpty(n, parent);
        }
        compiler.reportCodeChange();
      }
    }

    /**
     * Removes a method call if {@link #isMethodOrCtorCallThatTriggersRemoval}
     * indicates that it should be removed.
     *
     * @param t The traversal
     * @param n A CALL node
     * @param parent {@code n}'s parent
     */
    void maybeRemoveCall(NodeTraversal t, Node n, Node parent) {
      // CALL/NEW
      //   function
      //   arguments
      if (isMethodOrCtorCallThatTriggersRemoval(t, n, parent)) {
        replaceHighestNestedCallWithNull(n, parent);
      }
    }

    /**
     * Eliminates any object literal keys in an object literal declaration that
     * have strip names.
     *
     * @param t The traversal
     * @param n An OBJLIT node
     */
    void eliminateKeysWithStripNamesFromObjLit(NodeTraversal t, Node n) {
      // OBJLIT
      //   key1
      //     value1
      //   key2
      //   ...
      Node key = n.getFirstChild();
      while (key != null) {
        if (isStripName(key.getString())) {
          Node next = key.getNext();
          n.removeChild(key);
          key = next;
          compiler.reportCodeChange();
        } else {
          key = key.getNext();
        }
      }
    }

    /**
     * Gets whether a node is a CALL node whose return value should be
     * stripped. A call's return value should be stripped if the function
     * getting called is a static method in a class that gets stripped. For
     * example, if "goog.debug.Logger" is a strip name, then this function
     * returns true for a call such as "goog.debug.Logger.getLogger(...)".  It
     * may also simply be a function that is getting stripped.  For example,
     * if "getLogger" is a strip name, but not "goog.debug.Logger", this will
     * still return true.
     *
     * @param n A node (typically a CALL node)
     * @return Whether the call's return value should be stripped
     */
    boolean isCallWhoseReturnValueShouldBeStripped(@Nullable Node n) {
      return n != null &&
          (n.isCall() ||
           n.isNew()) &&
          n.hasChildren() &&
          (qualifiedNameBeginsWithStripType(n.getFirstChild()) ||
              nameEndsWithFieldNameToStrip(n.getFirstChild()));
    }

    /**
     * Gets whether a qualified name begins with a strip name. The names
     * "goog.debug", "goog.debug.Logger", and "goog.debug.Logger.Level" are
     * examples of strip names that would result in this function returning
     * true for a node representing the name "goog.debug.Logger.Level".
     *
     * @param n A node (typically a NAME or GETPROP node)
     * @return Whether the name begins with a strip name
     */
    boolean qualifiedNameBeginsWithStripType(Node n) {
      String name = n.getQualifiedName();
      return qualifiedNameBeginsWithStripType(name);
    }

    /**
     * Gets whether a qualified name begins with a strip name. The names
     * "goog.debug", "goog.debug.Logger", and "goog.debug.Logger.Level" are
     * examples of strip names that would result in this function returning
     * true for a node representing the name "goog.debug.Logger.Level".
     *
     * @param name A qualified class name
     * @return Whether the name begins with a strip name
     */
    boolean qualifiedNameBeginsWithStripType(String name) {
      if (name != null) {
        for (String type : stripTypes) {
          if (name.equals(type) || name.startsWith(type + ".")) {
            return true;
          }
        }
        for (String type : stripTypePrefixes) {
          if (name.startsWith(type)) {
            return true;
          }
        }
      }
      return false;
    }

    /**
     * Determines whether a NAME node represents a reference to a variable that
     * has been removed.
     *
     * @param t The traversal
     * @param n A NAME node
     * @return Whether the variable was removed
     */
    boolean isReferenceToRemovedVar(NodeTraversal t, Node n) {
      String name = n.getString();
      Scope scope = t.getScope();
      Scope.Var var = scope.getVar(name);
      return varsToRemove.contains(var);
    }

    /**
     * Gets whether a CALL node triggers statement removal, based on the name
     * of the object whose method is being called, or the name of the method.
     * Checks whether the name begins with a strip type, ends with a field name
     * that's a strip name, or belongs to the set of global class-defining
     * functions (e.g. goog.inherits).
     *
     * @param t The traversal
     * @param n A CALL node
     * @return Whether the node triggers statement removal
     */
    boolean isMethodOrCtorCallThatTriggersRemoval(
        NodeTraversal t, Node n, Node parent) {
      // CALL/NEW
      //   GETPROP (function)         <-- we're interested in this, the function
      //     GETPROP (callee object)  <-- or the object on which it is called
      //       ...
      //       STRING (field name)
      //     STRING (method name)
      //   ... (arguments)

      Node function = n.getFirstChild();
      if (function == null || !function.isGetProp()) {
        // We are only interested in calls on object references that are
        // properties. We don't need to eliminate method calls on variables
        // that are getting removed, since that's already done by the code
        // that removes all references to those variables.
        return false;
      }

      if (parent != null && parent.isName()) {
        Node gramps = parent.getParent();
        if (gramps != null && gramps.isVar()) {
          // The call's return value is being used to initialize a newly
          // declared variable. We should leave the call intact for now.
          // That way, when the traversal reaches the variable declaration,
          // we'll recognize that the variable and all references to it need
          // to be eliminated.
          return false;
        }
      }

      Node callee = function.getFirstChild();
      return nameEndsWithFieldNameToStrip(callee) ||
          nameEndsWithFieldNameToStrip(function) ||
          qualifiedNameBeginsWithStripType(function) ||
          actsOnStripType(t, n);
    }

    /**
     * Gets whether a name ends with a field name that should be stripped. For
     * example, this function would return true when passed "this.logger" or
     * "a.b.c.myLogger" if "logger" is a strip name.
     *
     * @param n A node (typically a GETPROP node)
     * @return Whether the name ends with a field name that should be stripped
     */
    boolean nameEndsWithFieldNameToStrip(@Nullable Node n) {
      if (n != null && n.isGetProp()) {
        Node propNode = n.getLastChild();
        return propNode != null && propNode.isString() &&
               isStripName(propNode.getString());
      }
      return false;
    }

    /**
     * Determines whether the given node helps to define a
     * strip type. For example, goog.inherits(stripType, Object)
     * would be such a call.
     *
     * Also reports an error if a non-strip type inherits from a strip type.
     *
     * @param t The current traversal
     * @param callNode The CALL node
     */
    private boolean actsOnStripType(NodeTraversal t, Node callNode) {
      SubclassRelationship classes =
          compiler.getCodingConvention().getClassesDefinedByCall(callNode);
      if (classes != null) {
        // It's okay to strip a type that inherits from a non-stripped type
        // e.g. goog.inherits(goog.debug.Logger, Object)
        if (qualifiedNameBeginsWithStripType(classes.subclassName)) {
          return true;
        }

        // report an error if a non-strip type inherits from a
        // strip type.
        if (qualifiedNameBeginsWithStripType(classes.superclassName)) {
          t.report(callNode, STRIP_TYPE_INHERIT_ERROR,
                   classes.subclassName, classes.superclassName);
        }
      }

      return false;
    }

    /**
     * Gets whether a JavaScript identifier is the name of a variable or
     * property that should be stripped.
     *
     * @param name A JavaScript identifier
     * @return Whether {@code name} is a name that triggers removal
     */
    boolean isStripName(String name) {
      if (stripNameSuffixes.contains(name) ||
          stripNamePrefixes.contains(name)) {
        return true;
      }

      if ((name.length() == 0) || Character.isUpperCase(name.charAt(0))) {
        return false;
      }

      String lcName = name.toLowerCase();
      for (String stripName : stripNamePrefixes) {
        if (lcName.startsWith(stripName.toLowerCase())) {
          return true;
        }
      }

      for (String stripName : stripNameSuffixes) {
        if (lcName.endsWith(stripName.toLowerCase())) {
          return true;
        }
      }

      return false;
    }

    /**
     * Replaces a node with a NULL node. This is useful where a value is
     * expected.
     *
     * @param n A node
     * @param parent {@code n}'s parent
     */
    void replaceWithNull(Node n, Node parent) {
      parent.replaceChild(n, IR.nullNode());
    }

    /**
     * Replaces a node with an EMPTY node. This is useful where a statement is
     * expected.
     *
     * @param n A node
     * @param parent {@code n}'s parent
     */
    void replaceWithEmpty(Node n, Node parent) {
      NodeUtil.removeChild(parent, n);
    }
  }
}
