/*
 * Copyright 2014 Google Inc.
 *
 * 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.gwt.dev.js;

import com.google.gwt.dev.jjs.SourceOrigin;
import com.google.gwt.dev.jjs.ast.JDeclaredType;
import com.google.gwt.dev.jjs.ast.JField;
import com.google.gwt.dev.jjs.ast.JMethod;
import com.google.gwt.dev.jjs.ast.JProgram;
import com.google.gwt.dev.jjs.impl.JavaToJavaScriptMap;
import com.google.gwt.dev.js.ast.JsBinaryOperation;
import com.google.gwt.dev.js.ast.JsBinaryOperator;
import com.google.gwt.dev.js.ast.JsContext;
import com.google.gwt.dev.js.ast.JsExprStmt;
import com.google.gwt.dev.js.ast.JsExpression;
import com.google.gwt.dev.js.ast.JsFunction;
import com.google.gwt.dev.js.ast.JsModVisitor;
import com.google.gwt.dev.js.ast.JsName;
import com.google.gwt.dev.js.ast.JsNameRef;
import com.google.gwt.dev.js.ast.JsObjectLiteral;
import com.google.gwt.dev.js.ast.JsProgram;
import com.google.gwt.dev.js.ast.JsStatement;
import com.google.gwt.dev.js.ast.JsVars;
import com.google.gwt.dev.js.ast.JsVars.JsVar;
import com.google.gwt.dev.util.Util;
import com.google.gwt.thirdparty.guava.common.collect.Lists;
import com.google.gwt.thirdparty.guava.common.collect.Maps;

import java.util.Collection;
import java.util.List;
import java.util.Map;

/**
 * A compiler pass that creates a namespace for each Java package
 * with at least one global variable or function.
 *
 * <p>Prerequisite: JsVarRefs must be resolved.</p>
 */
public class JsNamespaceChooser {

  public static void exec(JProgram jprogram, JsProgram jsprogram, JavaToJavaScriptMap jjsmap) {
    new JsNamespaceChooser(jprogram, jsprogram, jjsmap).execImpl();
  }

  private final JProgram jprogram;
  private final JsProgram jsprogram;
  private final JavaToJavaScriptMap jjsmap;

  /**
   * The namespaces to be added to the program.
   */
  private final Map<String, JsName> packageToNamespace = Maps.newLinkedHashMap();

  private JsNamespaceChooser(JProgram jprogram, JsProgram jsprogram, JavaToJavaScriptMap jjsmap) {
    this.jsprogram = jsprogram;
    this.jprogram = jprogram;
    this.jjsmap = jjsmap;
  }

  private void execImpl() {

    // First pass: visit each top-level statement in the program and move it if possible.
    // (This isn't a standard visitor because we don't want to recurse.)

    List<JsStatement> globalStatements = jsprogram.getGlobalBlock().getStatements();
    List<JsStatement> after = Lists.newArrayList();
    for (JsStatement before : globalStatements) {
      if (before instanceof JsVars) {
        for (JsVar var : ((JsVars) before)) {
          JsStatement replacement = visitGlobalVar(var);
          if (replacement != null) {
            after.add(replacement);
          }
        }
        continue;
      }

      if (before instanceof JsExprStmt) {
        JsExprStmt expressionStatement = (JsExprStmt) before;
        if (expressionStatement.getExpression() instanceof JsFunction) {
          JsExpression transformedFunction =
              visitGlobalFunction((JsFunction) expressionStatement.getExpression());
          expressionStatement.setExpression(transformedFunction);
        }
      }
      after.add(before);
    }

    after.addAll(0, createNamespaceInitializers(packageToNamespace.values()));

    globalStatements.clear();
    globalStatements.addAll(after);

    // Second pass: fix all references for moved names.
    new NameFixer().accept(jsprogram);
  }

  /**
   * Moves a global variable to a namespace if possible.
   * (The references must still be fixed up.)
   * @return the new initializer or null to delete it
   */
  private JsStatement visitGlobalVar(JsVar x) {
    JsName name = x.getName();

    if (!moveName(name)) {
      // We can't move it, but let's put the initializer on a separate line for readability.
      JsVars vars = new JsVars(x.getSourceInfo());
      vars.add(x);
      return vars;
    }

    // Convert the initializer from a var to an assignment.
    JsNameRef newName = name.makeRef(x.getSourceInfo());
    JsExpression init = x.getInitExpr();
    if (init == null) {
      // It's undefined so we don't need to initialize it at all.
      // (The namespace is sufficient.)
      return null;
    }
    JsBinaryOperation assign = new JsBinaryOperation(x.getSourceInfo(),
        JsBinaryOperator.ASG, newName, init);
    return assign.makeStmt();
  }

  /**
   * Moves a global function to a namespace if possible.
   * (References must still be fixed up.)
   * @return the new function definition.
   */
  private JsExpression visitGlobalFunction(JsFunction func) {
    JsName name = func.getName();
    if (name == null || !moveName(name)) {
      return func; // no change
    }

    // Convert the function statement into an assignment taking a named function expression:
    // a.b = function b() { ... }
    // The function also keeps its unqualified name for better stack traces in some browsers.
    // Note: for reserving names, currently we pretend that 'b' is in global scope to avoid
    // any name conflicts. It is actually two different names in two scopes; the 'b' in 'a.b'
    // is in the 'a' namespace scope and the function name is in a separate scope containing
    // just the function. We don't model either scope in the GWT compiler yet.
    JsNameRef newName = name.makeRef(func.getSourceInfo());
    JsBinaryOperation assign =
        new JsBinaryOperation(func.getSourceInfo(), JsBinaryOperator.ASG, newName, func);
    return assign;
  }

  /**
   * Creates a "var = {}" statement for each namespace.
   */
  private List<JsStatement> createNamespaceInitializers(Collection<JsName> namespaces) {
    // Let's list them vertically for readability.
    List<JsStatement> inits = Lists.newArrayList();
    for (JsName name : namespaces) {
      JsVar var = new JsVar(SourceOrigin.UNKNOWN, name);
      var.setInitExpr(JsObjectLiteral.EMPTY);
      JsVars vars = new JsVars(SourceOrigin.UNKNOWN);
      vars.add(var);
      inits.add(vars);
    }
    return inits;
  }

  /**
   * Attempts to move the given name to a namespace. Returns true if it was changed.
   * Side effects: may set the name's namespace and/or add a new mapping to
   * {@link #packageToNamespace}.
   */
  private boolean moveName(JsName name) {
    if (name.getNamespace() != null) {
      return false; // already in a namespace. (Shouldn't happen.)
    }

    if (!name.isObfuscatable()) {
      return false; // probably a JavaScript name
    }

    String packageName = findPackage(name);
    if (packageName == null) {
      return false; // not compiled from Java
    }

    if (isIndexedName(name)) {
      return false; // may be called directly in another pass (for example JsStackEmulator).
    }

    JsName namespace = packageToNamespace.get(packageName);
    if (namespace == null) {
      namespace = jsprogram.getScope().declareName(chooseUnusedName(packageName));
      packageToNamespace.put(packageName, namespace);
    }

    name.setNamespace(namespace);
    return true;
  }

  private boolean isIndexedName(JsName name) {
    return jprogram != null
        && (jprogram.getIndexedMethods().contains(jjsmap.nameToMethod(name))
            || jprogram.getIndexedFields().contains(jjsmap.nameToField(name)));
  }

  private String chooseUnusedName(String packageName) {
    String initials = initialsForPackage(packageName);
    String candidate = initials;
    int counter = 1;
    while (jsprogram.getScope().findExistingName(candidate) != null) {
      counter++;
      candidate = initials + counter;
    }
    return candidate;
  }

  /**
   * Find the Java package name for the given JsName, or null
   * if it couldn't be determined.
   */
  private String findPackage(JsName name) {
    JMethod method = jjsmap.nameToMethod(name);
    if (method != null) {
      return findPackage(method.getEnclosingType());
    }
    JField field = jjsmap.nameToField(name);
    if (field != null) {
      return findPackage(field.getEnclosingType());
    }
    return null; // not found
  }

  private static String findPackage(JDeclaredType type) {
    String packageName = Util.getPackageName(type.getName());
    // Return null for the default package.
    return packageName.isEmpty() ? null : packageName;
  }

  /**
   * Find the initials of a package. For example, "java.lang" -> "jl".
   */
  private static String initialsForPackage(String packageName) {
    StringBuilder result = new StringBuilder();

    int end = packageName.length();
    boolean wasDot = true;
    for (int i = 0; i < end; i++) {
      char c = packageName.charAt(i);
      if (c == '.') {
        wasDot = true;
        continue;
      }
      if (wasDot) {
        result.append(c);
      }
      wasDot = false;
    }

    return result.toString();
  }

  /**
   * A compiler pass that qualifies all moved names with the namespace.
   * name => namespace.name
   */
  private static class NameFixer extends JsModVisitor {

    @Override
    public void endVisit(JsNameRef x, JsContext ctx) {
      if (x.getQualifier() != null || x.getName() == null) {
        return;
      }

      JsName namespace = x.getName().getNamespace();
      if (namespace == null) {
        return;
      }

      x.setQualifier(new JsNameRef(x.getSourceInfo(), namespace));
      didChange = true;
    }
  }
}
