package com.google.javascript.gents.pass;

import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;

import com.google.common.base.MoreObjects;
import com.google.common.collect.HashBasedTable;
import com.google.common.collect.Table;
import com.google.javascript.gents.GentsErrorManager;
import com.google.javascript.gents.pass.CollectModuleMetadata.FileModule;
import com.google.javascript.gents.pass.comments.GeneralComment;
import com.google.javascript.gents.pass.comments.NodeComments;
import com.google.javascript.gents.util.GentsNodeUtil;
import com.google.javascript.gents.util.NameUtil;
import com.google.javascript.gents.util.PathUtil;
import com.google.javascript.jscomp.AbstractCompiler;
import com.google.javascript.jscomp.CompilerPass;
import com.google.javascript.jscomp.JSError;
import com.google.javascript.jscomp.NodeTraversal;
import com.google.javascript.jscomp.NodeTraversal.AbstractPreOrderCallback;
import com.google.javascript.jscomp.NodeUtil;
import com.google.javascript.rhino.IR;
import com.google.javascript.rhino.JSDocInfo;
import com.google.javascript.rhino.Node;
import com.google.javascript.rhino.Token;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/**
 * Converts Closure-style modules into TypeScript (ES6) modules (not namespaces). All module
 * metadata must be populated before running this CompilerPass.
 */
public final class ModuleConversionPass implements CompilerPass {

  private static final String EXPORTS = "exports";

  private final AbstractCompiler compiler;
  private final PathUtil pathUtil;
  private final NameUtil nameUtil;
  private final NodeComments nodeComments;
  private final NodeComments astComments;

  private final Map<String, FileModule> fileToModule;
  private final Map<String, FileModule> namespaceToModule;

  /**
   * Map of metadata about a potential export to the node that should be exported.
   *
   * <p>For example, in the case below, the {@code Node} would point to {@code class Foo}.
   * <code><pre>
   * class Foo {}
   * exports {Foo}
   * </pre><code>
   */
  private final Map<ExportedSymbol, Node> exportsToNodes = new HashMap<>();

  // Used for rewriting usages of imported symbols
  /** fileName, namespace -> local name */
  private final Table<String, String, String> valueRewrite = HashBasedTable.create();
  /** fileName, namespace -> local name */
  private final Table<String, String, String> typeRewrite = HashBasedTable.create();

  private final String alreadyConvertedPrefix;

  public Table<String, String, String> getTypeRewrite() {
    return typeRewrite;
  }

  public ModuleConversionPass(
      AbstractCompiler compiler,
      PathUtil pathUtil,
      NameUtil nameUtil,
      Map<String, FileModule> fileToModule,
      Map<String, FileModule> namespaceToModule,
      NodeComments nodeComments,
      NodeComments astComments,
      String alreadyConvertedPrefix) {
    this.compiler = compiler;
    this.pathUtil = pathUtil;
    this.nameUtil = nameUtil;
    this.nodeComments = nodeComments;
    this.astComments = astComments;

    this.fileToModule = fileToModule;
    this.namespaceToModule = namespaceToModule;
    this.alreadyConvertedPrefix = alreadyConvertedPrefix;
  }

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

  /**
   * Converts "exports" assignments into TypeScript export statements. This also builds a map of all
   * the declared modules.
   */
  private class ModuleExportConverter extends AbstractTopLevelCallback {
    @Override
    public void visit(NodeTraversal t, Node n, Node parent) {
      String fileName = n.getSourceFileName();
      if (n.isScript()) {
        if (fileToModule.containsKey(fileName)) {
          // Module is declared purely for side effects
          FileModule module = fileToModule.get(fileName);
          if (!module.hasImports() && !module.hasExports()) {
            // export {};
            Node commentNode = new Node(Token.EMPTY);
            commentNode.useSourceInfoFrom(n);
            nodeComments.addComment(
                commentNode,
                GeneralComment.from(
                    "\n// gents: force this file to be an ES6 module (no imports or exports)"));

            Node exportNode = new Node(Token.EXPORT, new Node(Token.EXPORT_SPECS, commentNode));
            commentNode.useSourceInfoFromForTree(n);

            if (n.hasChildren() && n.getFirstChild().isModuleBody()) {
              n.getFirstChild().addChildToFront(exportNode);
            } else {
              n.addChildToFront(exportNode);
            }
          }
        }
      }

      if (!n.isExprResult()) {
        if (n.isConst() || n.isClass() || n.isFunction() || n.isLet() || n.isConst() || n.isVar()) {
          collectMetdataForExports(n, fileName);
        }
        return;
      }

      Node child = n.getFirstChild();
      switch (child.getToken()) {
        case CALL:
          String callName = child.getFirstChild().getQualifiedName();
          if ("goog.module".equals(callName) || "goog.provide".equals(callName)) {
            // Remove the goog.module and goog.provide calls.
            if (nodeComments.hasComment(n)) {
              nodeComments.replaceWithComment(n, new Node(Token.EMPTY));
            } else {
              compiler.reportChangeToEnclosingScope(n);
              n.detach();
            }
          }
          break;
        case GETPROP:
          {
            JSDocInfo jsdoc = NodeUtil.getBestJSDocInfo(child);
            if (jsdoc == null || !jsdoc.containsTypeDefinition()) {
              // GETPROPs on the root level are only exports for @typedefs
              break;
            }
            if (!fileToModule.containsKey(fileName)) {
              break;
            }
            FileModule module = fileToModule.get(fileName);
            Map<String, String> symbols = module.exportedNamespacesToSymbols;
            String exportedNamespace = nameUtil.findLongestNamePrefix(child, symbols.keySet());
            if (exportedNamespace != null) {
              String localName = symbols.get(exportedNamespace);
              Node export =
                  new Node(Token.EXPORT, createExportSpecs(Node.newString(Token.NAME, localName)));
              export.useSourceInfoFromForTree(child);
              parent.addChildAfter(export, n);
              // Registers symbol for rewriting local uses.
              registerLocalSymbol(
                  child.getSourceFileName(), exportedNamespace, exportedNamespace, localName);
            }
            break;
          }
        case ASSIGN:
          if (!fileToModule.containsKey(fileName)) {
            break;
          }
          FileModule module = fileToModule.get(fileName);
          Node lhs = child.getFirstChild();
          Map<String, String> symbols = module.exportedNamespacesToSymbols;

          String exportedNamespace = nameUtil.findLongestNamePrefix(lhs, symbols.keySet());
          String exportedSymbol = null;

          if (exportedNamespace != null) {
            exportedSymbol = symbols.get(exportedNamespace);
          } else if (GentsNodeUtil.isObjLitWithSimpleRefs(child.getSecondChild())) {
            // Special case the exports = {A, B, C} pattern. The rewritter code
            // already handles this pattern, but we need to pick a non-null symbol to
            // proceed.
            // TODO(radokirov): refactor this to not care about exportedSymbol when
            // export pattern is detected.
            exportedNamespace = exportedSymbol = "exports";
          }
          if (exportedNamespace != null) {
            convertExportAssignment(child, exportedNamespace, exportedSymbol, fileName);
            // Registers symbol for rewriting local uses
            registerLocalSymbol(
                child.getSourceFileName(), exportedNamespace, exportedNamespace, exportedSymbol);
          }
          break;
        default:
          break;
      }
    }

    private void collectMetdataForExports(Node namedNode, String fileName) {
      if (!fileToModule.containsKey(fileName)) {
        return;
      }

      String nodeName = namedNode.getFirstChild().getQualifiedName();
      if (nodeName == null) {
        return;
      }
      exportsToNodes.put(ExportedSymbol.of(fileName, nodeName, nodeName), namedNode);
    }
  }

  /** Converts goog.require statements into TypeScript import statements. */
  private class ModuleImportConverter extends AbstractTopLevelCallback {
    @Override
    public void visit(NodeTraversal t, Node n, Node parent) {
      if (NodeUtil.isNameDeclaration(n)) {
        Node node = n.getFirstFirstChild();

        // var x = goog.require(...);
        if (isARequireLikeCall(node)) {
          Node callNode = node;
          String requiredNamespace = callNode.getLastChild().getString();
          String localName = n.getFirstChild().getQualifiedName();
          ModuleImport moduleImport =
              new ModuleImport(
                  n,
                  Collections.singletonList(localName),
                  Collections.emptyMap(),
                  requiredNamespace,
                  false);
          if (moduleImport.validateImport()) {
            convertNonDestructuringRequireToImportStatements(n, moduleImport);
          }
          return;
        }

        // var {foo, bar} = goog.require(...);
        if (isADestructuringRequireCall(node)) {
          Node importedNode = node.getFirstChild();

          ArrayList<String> namedExports = new ArrayList<String>();
          Map<String, String> aliases = new HashMap<>();
          while (importedNode != null) {
            namedExports.add(importedNode.getString());
            if (!importedNode.isShorthandProperty()) {
              aliases.put(importedNode.getString(), importedNode.getFirstChild().getString());
            }
            importedNode = importedNode.getNext();
          }
          String requiredNamespace = node.getNext().getFirstChild().getNext().getString();
          ModuleImport moduleImport =
              new ModuleImport(n, namedExports, aliases, requiredNamespace, true);
          if (moduleImport.validateImport()) {
            convertDestructuringRequireToImportStatements(n, moduleImport);
          }
          return;
        }
      } else if (n.isExprResult()) {
        // goog.require(...);
        Node callNode = n.getFirstChild();
        if (isARequireLikeCall(callNode)) {
          String requiredNamespace = callNode.getLastChild().getString();
          // For goog.require(...) imports, the full local name is just the required
          // namespace/module.
          // We use the suffix from the namespace as the local name, i.e. for
          // goog.require("a.b"), requiredNamespace = "a.b", fullLocalName = ["a.b"], localName =
          // ["b"]
          ModuleImport moduleImport =
              new ModuleImport(
                  n,
                  Collections.singletonList(requiredNamespace),
                  Collections.emptyMap(),
                  requiredNamespace,
                  false);
          if (moduleImport.validateImport()) {
            convertNonDestructuringRequireToImportStatements(n, moduleImport);
          }
          return;
        }
      }
    }

    private boolean isADestructuringRequireCall(Node node) {
      return node != null
          && node.isObjectPattern()
          && node.getNext().hasChildren()
          && isARequireLikeCall(node.getNext());
    }

    private boolean isARequireLikeCall(Node node) {
      return node != null
          && node.isCall()
          && (node.getFirstChild().matchesQualifiedName("goog.require")
              || node.getFirstChild().matchesQualifiedName("goog.requireType")
              || node.getFirstChild().matchesQualifiedName("goog.forwardDeclare"));
    }
  }

  /** Rewrites variable names used in the file to correspond to the newly imported symbols. */
  private class ModuleImportRewriter extends AbstractPreOrderCallback {
    @Override
    public boolean shouldTraverse(NodeTraversal t, Node n, Node parent) {
      // Rewrite all imported variable name usages
      if (n.isName() || n.isGetProp()) {
        if (!valueRewrite.containsRow(n.getSourceFileName())) {
          return true;
        }

        Map<String, String> rewriteMap = valueRewrite.rowMap().get(n.getSourceFileName());
        String importedNamespace = nameUtil.findLongestNamePrefix(n, rewriteMap.keySet());
        // Don't rename patterns like `const rewriteCandidate = ...`
        if (importedNamespace != null && !isDeclaration(n)) {
          nameUtil.replacePrefixInName(n, importedNamespace, rewriteMap.get(importedNamespace));
          return false;
        }
      }
      return true;
    }

    private boolean isDeclaration(Node n) {
      Node parent = n.getParent();
      return parent.isConst() || parent.isLet() || parent.isVar();
    }
  }

  /** A single statement containing {@code goog.require(...)}. */
  private class ModuleImport {
    /** require statement Node. */
    private Node originalImportNode;

    /**
     * LHS of the requirement statement. If the require statement has no LHS, then local name is the
     * suffix of the required namespace/module.
     *
     * <p>A bit of a misnomer, because when there is an alias this points to the name in the
     * exporter not at the importer. See localNameAliases.
     */
    private List<String> localNames;

    /**
     * Full local name is used for rewriting imported variables in the file later on. There's a one
     * to one relationship between fullLocalNames and localNames.
     *
     * <p>In goog.modules fullLocalNames always match localNames. This is only useful for
     * goog.provide files. TODO(radokirov): drop support for
     */
    private List<String> fullLocalNames;

    /**
     * In goog.modules, one can import and alias a name.
     *
     * <p>let {A: B} = goog.require('ns.foo'};
     *
     * <p>In this case the localName is 'A', the localAlias is 'B'. (Confusingly the fullLocalName
     * is also 'A').
     *
     * <p>This maps from the imported name ('A' in the example above), to the local alias ('B' in
     * the example above).
     */
    private Map<String, String> localNameAliases;

    /**
     * For {@code goog.require(...)} with no LHS and no side effects, then we use the required
     * namespace/module's suffix as the local name. The backup name is useful in this case to avoid
     * conflicts when the required namespace/module's suffix is the same with a named exported from
     * the same file.
     */
    private String backupName;

    /** The required namespace or module name */
    private String requiredNamespace;

    /**
     * {@code true}, if the original require statement contains destructuring imports. For
     * destructuring imports, there are one or more local names and full local names. For non
     * destructuring imports, there is exactly one local name and one full local name.
     */
    private boolean isDestructuringImport;

    /** FileModule for the imported file */
    private FileModule module;

    /** Referenced file */
    private String referencedFile;

    /**
     * The last part of the required namespace and module, used to detect local name conflicts and
     * calculate the backup name
     */
    private String moduleSuffix;

    private ModuleImport(
        Node originalImportNode,
        List<String> fullLocalNames,
        Map<String, String> localNameAliases,
        String requiredNamespace,
        boolean isDestructuringImport) {
      this.originalImportNode = originalImportNode;
      this.fullLocalNames = fullLocalNames;
      this.requiredNamespace = requiredNamespace;
      this.isDestructuringImport = isDestructuringImport;
      this.module = namespaceToModule.get(requiredNamespace);
      if (this.module != null) {
        this.referencedFile =
            pathUtil.getImportPath(originalImportNode.getSourceFileName(), module.file);
      }
      this.moduleSuffix = nameUtil.lastStepOfName(requiredNamespace);
      this.backupName = this.moduleSuffix;
      this.localNames = new ArrayList<String>();
      for (String fullLocalName : fullLocalNames) {
        String localName = nameUtil.lastStepOfName(fullLocalName);
        this.localNames.add(localName);
        if (this.moduleSuffix.equals(localName)) {
          this.backupName = this.moduleSuffix + "Exports";
        }
      }
      this.localNameAliases = localNameAliases;
    }

    /** Validate the module import assumptions */
    private boolean validateImport() {
      if (!isDestructuringImport && fullLocalNames.size() != 1) {
        compiler.report(
            JSError.make(
                originalImportNode,
                GentsErrorManager.GENTS_MODULE_PASS_ERROR,
                String.format(
                    "Non destructuring imports should have exactly one local name, got [%s]",
                    String.join(", ", fullLocalNames))));
        return false;
      }

      if (this.module == null && !isAlreadyConverted()) {
        compiler.report(
            JSError.make(
                originalImportNode,
                GentsErrorManager.GENTS_MODULE_PASS_ERROR,
                String.format("Module %s does not exist.", requiredNamespace)));
        return false;
      }
      return true;
    }

    /** Returns {@code true} if the imported file is already in TS */
    private boolean isAlreadyConverted() {
      return requiredNamespace.startsWith(alreadyConvertedPrefix + ".");
    }

    /** Returns {@code true} if is full module import, for example const x = goog.require(...) */
    private boolean isFullModuleImport() {
      Node requireLHS = this.originalImportNode.getFirstChild();
      return requireLHS != null && requireLHS.isName();
    }
  }

  /**
   * Converts a non destructuring Closure goog.require call into a TypeScript import statement.
   *
   * <p>The resulting node is dependent on the exports by the module being imported:
   *
   * <pre>
   *   import localName from "goog:old.namespace.syntax";
   *   import {A as localName, B} from "./valueExports";
   *   import * as localName from "./objectExports";
   *   import "./sideEffectsOnly"
   * </pre>
   */
  private void convertNonDestructuringRequireToImportStatements(Node n, ModuleImport moduleImport) {
    // The imported file is already in TS
    if (moduleImport.isAlreadyConverted()) {
      convertRequireForAlreadyConverted(moduleImport);
      return;
    }

    // The imported file is kept in JS
    if (moduleImport.module.shouldUseOldSyntax()) {
      convertRequireToImportsIfImportedIsKeptInJs(moduleImport);
      return;
    }
    // For the rest of the function, the imported and importing files are migrating together

    // For non destructuring imports, there is exactly one local name and full local name
    String localName = moduleImport.localNames.get(0);
    String fullLocalName = moduleImport.fullLocalNames.get(0);
    // If not imported then this is a side effect only import.
    boolean imported = false;

    if (moduleImport.module.importedNamespacesToSymbols.containsKey(
        moduleImport.requiredNamespace)) {
      // import {value as localName} from "./file"
      Node importSpec = new Node(Token.IMPORT_SPEC, IR.name(moduleImport.moduleSuffix));
      importSpec.setShorthandProperty(true);
      // import {a as b} only when a != b
      if (!moduleImport.moduleSuffix.equals(localName)) {
        importSpec.addChildToBack(IR.name(localName));
      }

      Node importNode =
          new Node(
              Token.IMPORT,
              IR.empty(),
              new Node(Token.IMPORT_SPECS, importSpec),
              Node.newString(moduleImport.referencedFile));
      addImportNode(n, importNode);
      imported = true;

      registerLocalSymbol(
          n.getSourceFileName(), fullLocalName, moduleImport.requiredNamespace, localName);
      // Switch to back up name if necessary
      localName = moduleImport.backupName;
    }

    if (moduleImport.module.providesObjectChildren.get(moduleImport.requiredNamespace).size() > 0) {
      // import * as var from "./file"
      Node importNode =
          new Node(
              Token.IMPORT,
              IR.empty(),
              Node.newString(Token.IMPORT_STAR, localName),
              Node.newString(moduleImport.referencedFile));
      addImportNode(n, importNode);
      imported = true;

      for (String child :
          moduleImport.module.providesObjectChildren.get(moduleImport.requiredNamespace)) {
        if (!valueRewrite.contains(n.getSourceFileName(), child)) {
          String fileName = n.getSourceFileName();
          registerLocalSymbol(
              fileName,
              fullLocalName + '.' + child,
              moduleImport.requiredNamespace + '.' + child,
              localName + '.' + child);
        }
      }
    }

    if (!imported) {
      // Convert the require to "import './sideEffectOnly';"
      convertRequireForSideEffectOnlyImport(moduleImport);
    }

    compiler.reportChangeToEnclosingScope(n);
    n.detach();
  }

  /**
   * Converts a destructuring Closure goog.require call into a TypeScript import statement.
   *
   * <p>The resulting node is dependent on the exports by the module being imported:
   *
   * <pre>
   *   import {A as localName, B} from "./valueExports";
   * </pre>
   */
  private void convertDestructuringRequireToImportStatements(Node n, ModuleImport moduleImport) {
    // The imported file is already in TS
    if (moduleImport.isAlreadyConverted()) {
      convertRequireForAlreadyConverted(moduleImport);
      return;
    }

    // The imported file is kept in JS
    if (moduleImport.module.shouldUseOldSyntax()) {
      convertRequireToImportsIfImportedIsKeptInJs(moduleImport);
      return;
    }
    // For the rest of the function, the imported and importing files are migrating together

    // import {localName} from "./file"
    Node importSpecs = createNamedImports(moduleImport);
    Node importNode =
        new Node(
            Token.IMPORT, IR.empty(), importSpecs, Node.newString(moduleImport.referencedFile));
    addImportNode(n, importNode);

    for (int i = 0; i < moduleImport.fullLocalNames.size(); i++) {
      registerLocalSymbol(
          n.getSourceFileName(),
          moduleImport.fullLocalNames.get(i),
          moduleImport.requiredNamespace,
          moduleImport.localNames.get(i));
    }

    compiler.reportChangeToEnclosingScope(n);
    n.detach();
  }

  /** If the imported file is kept in JS, then use the special "goog:namespace" syntax */
  private void convertRequireToImportsIfImportedIsKeptInJs(ModuleImport moduleImport) {
    Node nodeToImport = null;
    // For destructuring imports use `import {foo} from 'goog:bar';`
    if (moduleImport.isDestructuringImport) {
      nodeToImport = new Node(Token.OBJECTLIT);
      for (String localName : moduleImport.localNames) {
        nodeToImport.addChildToBack(Node.newString(Token.STRING_KEY, localName));
      }
      // For non destructuring imports, it is safe to assume there's only one localName
    } else if (moduleImport.module.namespaceHasDefaultExport.getOrDefault(
        moduleImport.requiredNamespace, false)) {
      // If it has a default export then use `import foo from 'goog:bar';`
      nodeToImport = Node.newString(Token.NAME, moduleImport.localNames.get(0));
    } else {
      // If it doesn't have a default export then use `import * as foo from 'goog:bar';`
      nodeToImport = Node.newString(Token.IMPORT_STAR, moduleImport.localNames.get(0));
    }

    Node importNode =
        new Node(
            Token.IMPORT,
            IR.empty(),
            nodeToImport,
            Node.newString("goog:" + moduleImport.requiredNamespace));
    nodeComments.replaceWithComment(moduleImport.originalImportNode, importNode);
    compiler.reportChangeToEnclosingScope(importNode);

    for (int i = 0; i < moduleImport.fullLocalNames.size(); i++) {
      registerLocalSymbol(
          moduleImport.originalImportNode.getSourceFileName(),
          moduleImport.fullLocalNames.get(i),
          moduleImport.requiredNamespace,
          moduleImport.localNames.get(i));
    }
  }

  private void convertRequireForAlreadyConverted(ModuleImport moduleImport) {
    // we cannot use referencedFile here, because usually it points to the ES5 js file that is
    // the output of TS, and not the original source TS file.
    // However, we can reverse map the goog.module name to a file name.
    // TODO(rado): sync this better with the mapping done in tsickle.
    String originalPath =
        moduleImport.requiredNamespace.replace(alreadyConvertedPrefix + ".", "").replace(".", "/");
    String sourceFileName = moduleImport.originalImportNode.getSourceFileName();
    String referencedFile = pathUtil.getImportPath(sourceFileName, originalPath);
    // goog.require('...'); -> import '...';
    Node importSpec = IR.empty();
    if (moduleImport.isDestructuringImport) {
      importSpec = createNamedImports(moduleImport);
    } else if (moduleImport.isFullModuleImport()) {
      // It is safe to assume there's one full local name because this is validated before.
      String fullLocalName = moduleImport.fullLocalNames.get(0);
      importSpec = Node.newString(Token.IMPORT_STAR, fullLocalName);
    }
    Node importNode =
        new Node(Token.IMPORT, IR.empty(), importSpec, Node.newString(referencedFile));
    nodeComments.replaceWithComment(moduleImport.originalImportNode, importNode);
    compiler.reportChangeToEnclosingScope(importNode);
  }

  private static Node createNamedImports(ModuleImport moduleImport) {
    Node importSpec = new Node(Token.IMPORT_SPECS);
    importSpec.setShorthandProperty(true);
    for (String fullLocalName : moduleImport.fullLocalNames) {
      Node spec = new Node(Token.IMPORT_SPEC, IR.name(fullLocalName));
      spec.setShorthandProperty(true);
      if (moduleImport.localNameAliases.containsKey(fullLocalName)) {
        spec.addChildToBack(IR.name(moduleImport.localNameAliases.get(fullLocalName)));
      }
      importSpec.addChildToBack(spec);
    }
    return importSpec;
  }

  private void convertRequireForSideEffectOnlyImport(ModuleImport moduleImport) {
    Node importNode =
        new Node(Token.IMPORT, IR.empty(), IR.empty(), Node.newString(moduleImport.referencedFile));
    addImportNode(moduleImport.originalImportNode, importNode);
  }

  /**
   * Converts a Closure assignment on a goog.module or goog.provide namespace into a TypeScript
   * export statement. This method should only be called on a node within a module.
   *
   * @param assign Assignment node
   * @param exportedNamespace The prefix of the assignment name that we are exporting
   * @param exportedSymbol The symbol that we want to export from the file For example,
   *     convertExportAssignment(pre.fix = ..., "pre.fix", "name") <-> export const name = ...
   *     convertExportAssignment(pre.fix.foo = ..., "pre.fix", "name") <-> name.foo = ...
   */
  private void convertExportAssignment(
      Node assign, String exportedNamespace, String exportedSymbol, String fileName) {
    checkState(assign.isAssign());
    checkState(assign.getParent().isExprResult());
    Node grandParent = assign.getGrandparent();
    checkState(
        grandParent.isScript() || grandParent.isModuleBody(),
        "export assignment must be in top level script or module body");

    Node exprNode = assign.getParent();
    Node lhs = assign.getFirstChild();
    Node rhs = assign.getLastChild();
    JSDocInfo jsDoc = NodeUtil.getBestJSDocInfo(assign);

    if (lhs.matchesQualifiedName(exportedNamespace)) {
      rhs.detach();
      if (GentsNodeUtil.isObjLitWithSimpleRefs(rhs)) {
        List<Node> aliases = new ArrayList<>();
        for (Node child : rhs.children()) {
          if (!child.hasChildren() || child.getString().equals(child.getFirstChild().getString())) {
            // We are in the simple case of exports = {..., A: B, ...}.
            ExportedSymbol symbolToExport =
                ExportedSymbol.fromExportAssignment(
                    child.getFirstChild(), exportedNamespace, child.getString(), fileName);
            Node exportNode = exportsToNodes.get(symbolToExport);
            if (exportNode != null) {
              moveExportStmtToADeclKeyword(assign, exportNode);
            }
          } else {
            // We are in the alias case of exports = {..., A: B, ...}.
            aliases.add(
                new Node(
                    Token.EXPORT_SPEC,
                    Node.newString(Token.NAME, child.getFirstChild().getString()),
                    Node.newString(Token.NAME, child.getString())));
          }
        }
        if (!aliases.isEmpty()) {
          Node exportSpecs = new Node(Token.EXPORT_SPECS);
          for (Node alias : aliases) {
            exportSpecs.addChildToBack(alias);
          }
          Node exportNode = new Node(Token.EXPORT, exportSpecs);
          exportNode.setJSDocInfo(jsDoc);
          nodeComments.replaceWithComment(exprNode, exportNode);
        } else {
          exprNode.detach();
        }
        return;
      }
      ExportedSymbol symbolToExport =
          ExportedSymbol.fromExportAssignment(rhs, exportedNamespace, exportedSymbol, fileName);
      if (rhs.isName() && exportsToNodes.containsKey(symbolToExport)) {
        moveExportStmtToADeclKeyword(assign, exportsToNodes.get(symbolToExport));
        exprNode.detach();
        return;
      }

      // Below the export node stays but is modified.
      Node exportSpecNode;
      if (rhs.isName() && exportedSymbol.equals(rhs.getString())) {
        // Rewrite the export line to: <code>export {rhs}</code>.
        exportSpecNode = createExportSpecs(rhs);
        exportSpecNode.useSourceInfoFrom(rhs);
      } else {
        // Rewrite the export line to: <code>export const exportedSymbol = rhs</code>.
        exportSpecNode = IR.constNode(IR.name(exportedSymbol), rhs);
        exportSpecNode.useSourceInfoFrom(rhs);
      }
      exportSpecNode.setJSDocInfo(jsDoc);
      Node exportNode = new Node(Token.EXPORT, exportSpecNode);
      nodeComments.replaceWithComment(exprNode, exportNode);

    } else {
      // Assume prefix has already been exported and just trim the prefix
      nameUtil.replacePrefixInName(lhs, exportedNamespace, exportedSymbol);
    }
  }

  /**
   * Takes an assignment statement (export or goog.provide one), removes it and instead finds the
   * matching declaration and adds an 'export' keyword.
   *
   * <p>Before: class C {} exports.C = C;
   *
   * <p>After: export class C {};
   */
  private void moveExportStmtToADeclKeyword(Node assignmentNode, Node declarationNode) {
    // Rewrite the AST to export the symbol directly using information from the export
    // assignment.
    Node next = declarationNode.getNext();
    Node parent = declarationNode.getParent();
    declarationNode.detach();

    Node export = new Node(Token.EXPORT, declarationNode);
    export.useSourceInfoFrom(assignmentNode);

    nodeComments.moveComment(declarationNode, export);
    astComments.moveComment(declarationNode, export);
    parent.addChildBefore(export, next);
    compiler.reportChangeToEnclosingScope(parent);
  }

  /** Creates an ExportSpecs node, which is the {...} part of an "export {name}" node. */
  private static Node createExportSpecs(Node name) {
    Node exportSpec = new Node(Token.EXPORT_SPEC, name);
    // Set the "is shorthand" property so that we "export {x}", not "export {x as x}".
    exportSpec.setShorthandProperty(true);
    return new Node(Token.EXPORT_SPECS, exportSpec);
  }

  /** Saves the local name for imported symbols to be used for code rewriting later. */
  void registerLocalSymbol(
      String sourceFile, String fullLocalName, String requiredNamespace, String localName) {
    valueRewrite.put(sourceFile, fullLocalName, localName);
    typeRewrite.put(sourceFile, fullLocalName, localName);
    typeRewrite.put(sourceFile, requiredNamespace, localName);
  }

  private void addImportNode(Node n, Node importNode) {
    importNode.useSourceInfoFromForTree(n);
    n.getParent().addChildBefore(importNode, n);
    nodeComments.moveComment(n, importNode);
  }

  /** Metadata about an exported symbol. */
  private static final class ExportedSymbol {
    /** The name of the file exporting the symbol. */
    final String fileName;

    /**
     * The name that the symbol is declared in the module.
     *
     * <p>For example, {@code Foo} in: <code> class Foo {}</code>
     */
    final String localName;

    /**
     * The name that the symbol is exported under via named exports.
     *
     * <p>For example, {@code Bar} in: <code> exports {Bar}</code>.
     *
     * <p>If a symbol is directly exported, as in the case of <code>export class Foo {}</code>, the
     * {@link #localName} and {@link #exportedName} will both be {@code Foo}.
     */
    final String exportedName;

    private ExportedSymbol(String fileName, String localName, String exportedName) {
      this.fileName = checkNotNull(fileName);
      this.localName = checkNotNull(localName);
      this.exportedName = checkNotNull(exportedName);
    }

    static ExportedSymbol of(String fileName, String localName, String exportedName) {
      return new ExportedSymbol(fileName, localName, exportedName);
    }

    static ExportedSymbol fromExportAssignment(
        Node rhs, String exportedNamespace, String exportedSymbol, String fileName) {
      String localName = (rhs.getQualifiedName() != null) ? rhs.getQualifiedName() : exportedSymbol;

      String exportedName;
      if (exportedNamespace.equals(EXPORTS)) { // is a default export
        exportedName = localName;
      } else if (exportedNamespace.startsWith(EXPORTS)) { // is a named export
        exportedName =
            exportedNamespace.substring(EXPORTS.length() + 1, exportedNamespace.length());
      } else { // exported via goog.provide
        exportedName = exportedSymbol;
      }

      return new ExportedSymbol(fileName, localName, exportedName);
    }

    @Override
    public String toString() {
      return MoreObjects.toStringHelper(getClass())
          .add("fileName", fileName)
          .add("localName", localName)
          .add("exportedName", exportedName)
          .toString();
    }

    @Override
    public int hashCode() {
      return Objects.hash(this.fileName, this.localName, this.exportedName);
    }

    @Override
    public boolean equals(Object obj) {
      if (this == obj) {
        return true;
      }
      if (obj == null) {
        return false;
      }
      if (getClass() != obj.getClass()) {
        return false;
      }
      ExportedSymbol that = (ExportedSymbol) obj;
      return Objects.equals(this.fileName, that.fileName)
          && Objects.equals(this.localName, that.localName)
          && Objects.equals(this.exportedName, that.exportedName);
    }
  }
}