/*
 * Copyright 2011 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 com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableSet;
import com.google.javascript.jscomp.CompilerOptions.LanguageMode;
import com.google.javascript.jscomp.NodeTraversal.AbstractPostOrderCallback;
import com.google.javascript.jscomp.NodeTraversal.ScopedCallback;
import com.google.javascript.rhino.IR;
import com.google.javascript.rhino.Node;
import com.google.javascript.rhino.Token;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.logging.Logger;

/**
 * Replace known jQuery aliases and methods with standard
 * conventions so that the compiler recognizes them. Expected
 * replacements include:
 *  - jQuery.fn -> jQuery.prototype
 *  - jQuery.extend -> expanded into direct object assignments
 *  - jQuery.expandedEach -> expand into direct assignments
 *
 * @author chadkillingsworth@missouristate.edu (Chad Killingsworth)
 */
class ExpandJqueryAliases extends AbstractPostOrderCallback
    implements CompilerPass {
  private final AbstractCompiler compiler;
  private final CodingConvention convention;
  private static final Logger logger =
      Logger.getLogger(ExpandJqueryAliases.class.getName());

  static final DiagnosticType JQUERY_UNABLE_TO_EXPAND_INVALID_LIT_ERROR =
      DiagnosticType.warning("JSC_JQUERY_UNABLE_TO_EXPAND_INVALID_LIT",
          "jQuery.expandedEach call cannot be expanded because the first " +
          "argument must be an object literal or an array of strings " +
          "literal.");

  static final DiagnosticType JQUERY_UNABLE_TO_EXPAND_INVALID_NAME =
      DiagnosticType.error("JSC_JQUERY_UNABLE_TO_EXPAND_INVALID_NAME",
          "jQuery.expandedEach expansion would result in an invalid property name.");

  static final DiagnosticType JQUERY_UNABLE_TO_EXPAND_INVALID_NAME_WITH_NAME =
      DiagnosticType.error("JSC_JQUERY_UNABLE_TO_EXPAND_INVALID_NAME_WITH_NAME",
          "jQuery.expandedEach expansion would result in the invalid " +
          "property name \"{0}\".");

  static final DiagnosticType JQUERY_USELESS_EACH_EXPANSION =
      DiagnosticType.warning("JSC_JQUERY_USELESS_EACH_EXPANSION",
          "jQuery.expandedEach was not expanded as no valid property " +
          "assignments were encountered. Consider using jQuery.each instead.");

  private static final Set<String> JQUERY_EXTEND_NAMES = ImmutableSet.of(
      "jQuery.extend", "jQuery.fn.extend", "jQuery.prototype.extend");

  private static final String JQUERY_EXPANDED_EACH_NAME =
      "jQuery.expandedEach";

  private final PeepholeOptimizationsPass peepholePasses;

  ExpandJqueryAliases(AbstractCompiler compiler) {
    this.compiler = compiler;
    this.convention = compiler.getCodingConvention();

    // All of the "early" peephole optimizations.
    // These passes should make the code easier to analyze.
    // Passes, such as StatementFusion, are omitted for this reason.
    final boolean late = false;
    this.peepholePasses = new PeepholeOptimizationsPass(compiler,
        new PeepholeMinimizeConditions(late),
        new PeepholeSubstituteAlternateSyntax(late),
        new PeepholeReplaceKnownMethods(late),
        new PeepholeRemoveDeadCode(),
        new PeepholeFoldConstants(late, compiler.getOptions().useTypesForOptimization),
        new PeepholeCollectPropertyAssignments());
  }

  /**
   * Check that Node n is a call to one of the jQuery.extend methods that we
   * can expand. Valid calls are single argument calls where the first argument
   * is an object literal or two argument calls where the first argument
   * is a name and the second argument is an object literal.
   */
  public static boolean isJqueryExtendCall(Node n, String qname,
      AbstractCompiler compiler) {
    if (JQUERY_EXTEND_NAMES.contains(qname)) {
      Node firstArgument = n.getNext();
      if (firstArgument == null) {
        return false;
      }

      Node secondArgument = firstArgument.getNext();
      if ((firstArgument.isObjectLit() && secondArgument == null) ||
          (firstArgument.isName() || NodeUtil.isGet(firstArgument) &&
          !NodeUtil.mayHaveSideEffects(firstArgument, compiler) &&
          secondArgument != null && secondArgument.isObjectLit() &&
          secondArgument.getNext() == null)) {
        return true;
      }
    }
    return false;
  }

  public boolean isJqueryExpandedEachCall(Node call, String qName) {
    Preconditions.checkArgument(call.isCall());
    return call.getFirstChild() != null && JQUERY_EXPANDED_EACH_NAME.equals(qName);
  }

  @Override
  public void visit(NodeTraversal t, Node n, Node parent) {
    if (n.isGetProp() && convention.isPrototypeAlias(n)) {
      maybeReplaceJqueryPrototypeAlias(n);

    } else if (n.isCall()) {
      Node callTarget = n.getFirstChild();
      String qName = callTarget.getQualifiedName();

      if (isJqueryExtendCall(callTarget, qName, this.compiler)) {
        maybeExpandJqueryExtendCall(n);

      } else if (isJqueryExpandedEachCall(n, qName)) {
        maybeExpandJqueryEachCall(t, n);
      }
    }
  }

  @Override
  public void process(Node externs, Node root) {
    logger.fine("Expanding Jquery Aliases");

    NodeTraversal.traverseEs6(compiler, root, this);
  }

  private void maybeReplaceJqueryPrototypeAlias(Node n) {
    // Check to see if this is the assignment of the original alias.
    // If so, leave it intact.
    if (NodeUtil.isLValue(n)) {
      Node maybeAssign = n.getParent();
      while (!NodeUtil.isStatement(maybeAssign) && !maybeAssign.isAssign()) {
        maybeAssign = maybeAssign.getParent();
      }

      if (maybeAssign.isAssign()) {
        maybeAssign = maybeAssign.getParent();
        if (maybeAssign.isBlock() || maybeAssign.isScript() ||
            NodeUtil.isStatement(maybeAssign)) {
          return;
        }
      }
    }

    Node fn = n.getLastChild();
    if (fn != null) {
      n.replaceChild(fn, IR.string("prototype").srcref(fn));
      compiler.reportCodeChange();
    }
  }

  /**
   * Expand jQuery.extend (and derivative) calls into direct object assignments
   * Example: jQuery.extend(obj1, {prop1: val1, prop2: val2}) ->
   *   obj1.prop1 = val1;
   *   obj1.prop2 = val2;
   */
  private void maybeExpandJqueryExtendCall(Node n) {
    Node callTarget = n.getFirstChild();
    Node objectToExtend = callTarget.getNext(); // first argument
    Node extendArg = objectToExtend.getNext(); // second argument
    boolean ensureObjectDefined = true;

    if (extendArg == null) {
      // Only one argument was specified, so extend jQuery namespace
      extendArg = objectToExtend;
      objectToExtend = callTarget.getFirstChild();
      ensureObjectDefined = false;
    } else if (objectToExtend.isGetProp() &&
          (objectToExtend.getLastChild().getString().equals("prototype") ||
          convention.isPrototypeAlias(objectToExtend))) {
      ensureObjectDefined = false;
    }

    // Check for an empty object literal
    if (!extendArg.hasChildren()) {
      return;
    }

    // Since we are expanding jQuery.extend calls into multiple statements,
    // encapsulate the new statements in a new block.
    Node fncBlock = IR.block().srcref(n);

    if (ensureObjectDefined) {
      Node assignVal = IR.or(objectToExtend.cloneTree(),
          IR.objectlit().srcref(n)).srcref(n);
      Node assign = IR.assign(objectToExtend.cloneTree(), assignVal).srcref(n);
      fncBlock.addChildrenToFront(IR.exprResult(assign).srcref(n));
    }

    while (extendArg.hasChildren()) {
      Node currentProp = extendArg.removeFirstChild();
      Node propValue;
      if (currentProp.hasChildren()) {
        propValue = currentProp.getLastChild().detachFromParent();
      } else {
        propValue = IR.name(currentProp.getString()).srcref(currentProp);
      }

      Node newProp;
      if (currentProp.isQuotedString()) {
        newProp = IR.getelem(objectToExtend.cloneTree(),
            currentProp).srcref(currentProp);
      } else if (currentProp.isComputedProp()) {
        Node childOfcompProp = currentProp.removeFirstChild();
        newProp = IR.getelem(objectToExtend.cloneTree(),
            childOfcompProp).srcref(currentProp);
      } else {
        currentProp.setType(Token.STRING);
        newProp = IR.getprop(objectToExtend.cloneTree(),
            currentProp).srcref(currentProp);
      }

      Node assignNode = IR.assign(newProp, propValue).srcref(currentProp);
      fncBlock.addChildToBack(IR.exprResult(assignNode).srcref(currentProp));
    }

    // Check to see if the return value is used. If not, replace the original
    // call with new block. Otherwise, wrap the statements in an
    // immediately-called anonymous function.
    if (n.getParent().isExprResult()) {
      Node parent = n.getParent();
      parent.getParent().replaceChild(parent, fncBlock);
    } else {
      Node targetVal;
      if ("jQuery.prototype".equals(objectToExtend.getQualifiedName())) {
        // When extending the jQuery prototype, return the jQuery namespace.
        // This is not commonly used.
        targetVal = objectToExtend.removeFirstChild();
      } else {
        targetVal = objectToExtend.detachFromParent();
      }
      fncBlock.addChildToBack(IR.returnNode(targetVal).srcref(targetVal));

      Node fnc = IR.function(IR.name("").srcref(n),
          IR.paramList().srcref(n),
          fncBlock).srcref(n);

      // add an explicit "call" statement so that we can maintain
      // the same reference for "this"
      Node newCallTarget = IR.getprop(
          fnc, IR.string("call").srcref(n)).srcref(n);
      n.replaceChild(callTarget, newCallTarget);
      n.putBooleanProp(Node.FREE_CALL, false);

      // remove any other pre-existing call arguments
      while (newCallTarget.getNext() != null) {
        n.removeChildAfter(newCallTarget);
      }
      n.addChildToBack(IR.thisNode().srcref(n));
    }
    compiler.reportCodeChange();
  }

  /**
   * Expand a jQuery.expandedEach call
   *
   * Expanded jQuery.expandedEach calls will replace the GETELEM nodes of a
   * property assignment with GETPROP nodes to allow for renaming.
   */
  private void maybeExpandJqueryEachCall(NodeTraversal t, Node n) {
    Node objectToLoopOver = n.getSecondChild();

    if (objectToLoopOver == null) {
      return;
    }

    Node callbackFunction = objectToLoopOver.getNext();
    if (callbackFunction == null || !callbackFunction.isFunction()) {
      return;
    }

    // Run the peephole optimizations on the first argument to handle
    // cases like ("a " + "b").split(" ")
    peepholePasses.process(null, n.getSecondChild());

    // Create a reference tree
    Node nClone = n.cloneTree();

    objectToLoopOver = nClone.getSecondChild();

    // Check to see if the first argument is something we recognize and can
    // expand.
    if (!objectToLoopOver.isObjectLit() &&
        !(objectToLoopOver.isArrayLit() &&
        isArrayLitValidForExpansion(objectToLoopOver))) {
      t.report(n, JQUERY_UNABLE_TO_EXPAND_INVALID_LIT_ERROR, (String) null);
      return;
    }

    // Find all references to the callback function arguments
    List<Node> keyNodeReferences = new ArrayList<>();
    List<Node> valueNodeReferences = new ArrayList<>();

    new NodeTraversal(
        compiler,
        new FindCallbackArgumentReferences(callbackFunction,
            keyNodeReferences, valueNodeReferences,
            objectToLoopOver.isArrayLit()))
        .traverseInnerNode(
            NodeUtil.getFunctionBody(callbackFunction), callbackFunction, t.getScope());

    if (keyNodeReferences.isEmpty()) {
     // We didn't do anything useful ...
      t.report(n, JQUERY_USELESS_EACH_EXPANSION, (String) null);
      return;
    }

    Node fncBlock = tryExpandJqueryEachCall(t, nClone, callbackFunction,
        keyNodeReferences, valueNodeReferences);

    if (fncBlock != null && fncBlock.hasChildren()) {
        replaceOriginalJqueryEachCall(n, fncBlock);
    } else {
      // We didn't do anything useful ...
      t.report(n, JQUERY_USELESS_EACH_EXPANSION, (String) null);
    }
  }

  private Node tryExpandJqueryEachCall(NodeTraversal t, Node n,
      Node callbackFunction, List<Node> keyNodes, List<Node> valueNodes) {

    Node callTarget = n.getFirstChild();
    Node objectToLoopOver = callTarget.getNext();

    // New block to contain the expanded statements
    Node fncBlock = IR.block().srcref(callTarget);

    boolean isValidExpansion = true;

    // Expand the jQuery.expandedEach call
    Node key = objectToLoopOver.getFirstChild(), val = null;
    for (int i = 0; key != null; key = key.getNext(), i++) {
      if (key != null) {
        if (objectToLoopOver.isArrayLit()) {
          // Arrays have a value of their index number
          val = IR.number(i).srcref(key);
        } else {
          val = key.getFirstChild();
          if (val == null) {
            val = IR.name(key.getString());
          }
        }
      }

      // Keep track of the replaced nodes so we can reset the tree
      List<Node> newKeys = new ArrayList<>();
      List<Node> newValues = new ArrayList<>();
      List<Node> origGetElems = new ArrayList<>();
      List<Node> newGetProps = new ArrayList<>();

      // Replace all of the key nodes with the prop name
      for (int j = 0; j < keyNodes.size(); j++) {
        if (key.isComputedProp()) {
          t.report(key, JQUERY_UNABLE_TO_EXPAND_INVALID_NAME);
          return null;
        }
        Node origNode = keyNodes.get(j);
        Node ancestor = origNode.getParent();

        Node newNode = IR.string(key.getString()).srcref(key);
        newKeys.add(newNode);
        ancestor.replaceChild(origNode, newNode);

        // Walk up the tree to see if the key is used in a GETELEM
        // assignment
        while (ancestor != null && !NodeUtil.isStatement(ancestor) &&
            !ancestor.isGetElem()) {
          ancestor = ancestor.getParent();
        }

        // Convert GETELEM nodes to GETPROP nodes so that they can be
        // renamed or removed.
        if (ancestor != null && ancestor.isGetElem()) {

          Node propObject = ancestor;
          while (propObject.isGetProp() || propObject.isGetElem()) {
            propObject = propObject.getFirstChild();
          }

          Node ancestorClone = ancestor.cloneTree();
          // Run the peephole passes to handle cases such as
          // obj['lit' + key] = val;
          peepholePasses.process(null, ancestorClone.getSecondChild());
          Node prop = ancestorClone.getSecondChild();

          if (prop.isString() &&
            NodeUtil.isValidPropertyName(LanguageMode.ECMASCRIPT3, prop.getString())) {
            Node target = ancestorClone.getFirstChild();
            Node newGetProp = IR.getprop(target.detachFromParent(),
                prop.detachFromParent());
            newGetProps.add(newGetProp);
            origGetElems.add(ancestor);
            ancestor.getParent().replaceChild(ancestor, newGetProp);
          } else {
            if (prop.isString() &&
                !NodeUtil.isValidPropertyName(LanguageMode.ECMASCRIPT3, prop.getString())) {
              t.report(n,
                  JQUERY_UNABLE_TO_EXPAND_INVALID_NAME_WITH_NAME,
                  prop.getString());
            }
            isValidExpansion = false;
          }
        }
      }

      if (isValidExpansion) {
        // Replace all of the value nodes with the prop value
        for (int j = 0; val != null && j < valueNodes.size(); j++) {
          Node origNode = valueNodes.get(j);
          Node newNode = val.cloneTree();
          newValues.add(newNode);
          origNode.getParent().replaceChild(origNode, newNode);
        }

        // Wrap the new tree in an anonymous function call
        Node fnc = IR.function(IR.name("").srcref(key),
            IR.paramList().srcref(key),
            callbackFunction.getChildAtIndex(2).cloneTree()).srcref(key);
        Node call = IR.call(fnc).srcref(key);
        call.putBooleanProp(Node.FREE_CALL, true);
        fncBlock.addChildToBack(IR.exprResult(call).srcref(call));
      }

      // Reset the source tree
      for (int j = 0; j < newGetProps.size(); j++) {
        newGetProps.get(j).getParent().replaceChild(newGetProps.get(j),
            origGetElems.get(j));
      }
      for (int j = 0; j < newKeys.size(); j++) {
        newKeys.get(j).getParent().replaceChild(newKeys.get(j),
            keyNodes.get(j));
      }
      for (int j = 0; j < newValues.size(); j++) {
        newValues.get(j).getParent().replaceChild(newValues.get(j),
            valueNodes.get(j));
      }

      if (!isValidExpansion) {
        return null;
      }
    }
    return fncBlock;
  }

  private void replaceOriginalJqueryEachCall(Node n, Node expandedBlock) {
    // Check to see if the return value of the original jQuery.expandedEach
    // call is used. If so, we need to wrap each loop expansion in an anonymous
    // function and return the original objectToLoopOver.
    if (n.getParent().isExprResult()) {
      Node parent = n.getParent();
      Node grandparent = parent.getParent();
      Node insertAfter = parent;
      while (expandedBlock.hasChildren()) {
        Node child = expandedBlock.getFirstChild().detachFromParent();
        grandparent.addChildAfter(child, insertAfter);
        insertAfter = child;
      }
      grandparent.removeChild(parent);
    } else {
      // Return the original object
      Node callTarget = n.getFirstChild();
      Node objectToLoopOver = callTarget.getNext();

      objectToLoopOver.detachFromParent();
      Node ret = IR.returnNode(objectToLoopOver).srcref(callTarget);
      expandedBlock.addChildToBack(ret);

      // Wrap all of the expanded loop calls in a new anonymous function
      Node fnc = IR.function(IR.name("").srcref(callTarget),
          IR.paramList().srcref(callTarget),
          expandedBlock);
      n.replaceChild(callTarget, fnc);
      n.putBooleanProp(Node.FREE_CALL, true);

      // remove any other pre-existing call arguments
      while (fnc.getNext() != null) {
        n.removeChildAfter(fnc);
      }
    }
    compiler.reportCodeChange();
  }

  private boolean isArrayLitValidForExpansion(Node n) {
    Iterator<Node> iter = n.children().iterator();
    while (iter.hasNext()) {
      Node child = iter.next();
      if (!child.isString()) {
        return false;
      }
    }
    return true;
  }

  /**
   * Given a jQuery.expandedEach callback function, traverse it and collect any
   * references to its parameter names.
   */
  static class FindCallbackArgumentReferences extends AbstractPostOrderCallback
      implements ScopedCallback {

    private final String keyName;
    private final String valueName;
    private Scope startingScope;
    private List<Node> keyReferences;
    private List<Node> valueReferences;

    FindCallbackArgumentReferences(Node functionRoot, List<Node> keyReferences,
        List<Node> valueReferences, boolean useArrayMode) {
      Preconditions.checkState(functionRoot.isFunction());

      String keyString = null, valueString = null;
      Node callbackParams = NodeUtil.getFunctionParameters(functionRoot);
      Node param = callbackParams.getFirstChild();
      if (param != null) {
        Preconditions.checkState(param.isName());
        keyString = param.getString();

        param = param.getNext();
        if (param != null) {
          Preconditions.checkState(param.isName());
          valueString = param.getString();
        }
      }

      this.keyName = keyString;
      this.valueName = valueString;

      // For arrays, the keyString is the index number of the element.
      // We're interested in the value of the element instead
      if (useArrayMode) {
        this.keyReferences = valueReferences;
        this.valueReferences = keyReferences;
      } else {
        this.keyReferences = keyReferences;
        this.valueReferences = valueReferences;
      }

      this.startingScope = null;
    }

    private boolean isShadowed(String name, Scope scope) {
      Var nameVar = scope.getVar(name);
      return nameVar != null &&
          nameVar.getScope() != this.startingScope;
    }

    @Override
    public void visit(NodeTraversal t, Node n, Node parent) {
      // In the top scope, "this" is a reference to "value"
      boolean isThis = false;
      if (t.getClosestHoistScope() == this.startingScope) {
        isThis = n.isThis();
      }

      if (isThis || n.isName() && !isShadowed(n.getString(), t.getScope())) {
        String nodeValue = isThis ? null : n.getString();
        if (!isThis && keyName != null && nodeValue.equals(keyName)) {
          keyReferences.add(n);
        } else if (isThis || (valueName != null &&
            nodeValue.equals(valueName))) {
          valueReferences.add(n);
        }
      }
    }

    /**
     * As we enter each scope, make sure that the scope doesn't define
     * a local variable with the same name as our original callback method
     * parameter names.
     */
    @Override
    public void enterScope(NodeTraversal t) {
      if (this.startingScope == null) {
        this.startingScope = t.getScope();
      }
    }

    @Override
    public void exitScope(NodeTraversal t) { }
  }
}
