'use strict';

const matchesPattern = require('./matches-pattern');
const t = require('babel-types');
const template = require('babel-template');

const WRAPPER_TEMPLATE = template(`
  var NAME = (function () {
    var exports = this;
    var module = {exports: this};
    BODY;
    return module.exports;
  }).call({});
`);

const EXPORT_ASSIGN_TEMPLATE = template('EXPORTS.NAME = LOCAL');
const REQUIRE_CALL_TEMPLATE = template('$parcel$require(ID, SOURCE)');
const TYPEOF = {
  module: 'object',
  require: 'function'
};

module.exports = {
  Program: {
    enter(path, asset) {
      asset.cacheData.exports = {};
      asset.cacheData.wildcards = [];

      let shouldWrap = false;
      path.traverse({
        CallExpression(path) {
          // If we see an `eval` call, wrap the module in a function.
          // Otherwise, local variables accessed inside the eval won't work.
          let callee = path.node.callee;
          if (t.isIdentifier(callee) && callee.name === 'eval' && !path.scope.hasBinding('eval', true)) {
            shouldWrap = true;
            path.stop();
          }
        },

        ReturnStatement(path) {
          // Wrap in a function if we see a top-level return statement.
          if (path.getFunctionParent().isProgram()) {
            shouldWrap = true;
            path.replaceWith(t.returnStatement(t.memberExpression(t.identifier('module'), t.identifier('exports'))));
            path.stop();
          }
        },

        ReferencedIdentifier(path) {
          // We must wrap if `module` is referenced as a free identifier rather
          // than a statically resolvable member expression.
          if (path.node.name === 'module' && (!path.parentPath.isMemberExpression() || path.parent.computed) && !(path.parentPath.isUnaryExpression() && path.parent.operator === 'typeof') && !path.scope.hasBinding('module') && !path.scope.getData('shouldWrap')) {
            shouldWrap = true;
            path.stop();
          }
        }
      });

      path.scope.setData('shouldWrap', shouldWrap);
    },

    exit(path, asset) {
      let scope = path.scope;

      if (scope.getData('shouldWrap')) {
        path.replaceWith(t.program([WRAPPER_TEMPLATE({
          NAME: getExportsIdentifier(asset),
          BODY: path.node.body
        })]));
      } else {
        // Re-crawl scope so we are sure to have all bindings.
        scope.crawl();

        // Rename each binding in the top-level scope to something unique.
        for (let name in scope.bindings) {
          if (!name.startsWith('$' + asset.id)) {
            let newName = '$' + asset.id + '$var$' + name;
            scope.rename(name, newName);
          }
        }
      }

      path.stop();
      asset.isAstDirty = true;
    }
  },

  MemberExpression(path, asset) {
    if (path.scope.hasBinding('module') || path.scope.getData('shouldWrap')) {
      return;
    }

    if (matchesPattern(path.node, 'module.exports')) {
      path.replaceWith(getExportsIdentifier(asset));
    }

    if (matchesPattern(path.node, 'module.id')) {
      path.replaceWith(t.numericLiteral(asset.id));
    }

    if (matchesPattern(path.node, 'module.hot')) {
      path.replaceWith(t.identifier('null'));
    }

    if (matchesPattern(path.node, 'module.bundle.modules')) {
      path.replaceWith(t.memberExpression(t.identifier('require'), t.identifier('modules')));
    }
  },

  ReferencedIdentifier(path, asset) {
    if (path.node.name === 'exports' && !path.scope.hasBinding('exports') && !path.scope.getData('shouldWrap')) {
      path.replaceWith(getExportsIdentifier(asset));
    }
  },

  ThisExpression(path, asset) {
    if (!path.scope.parent && !path.scope.getData('shouldWrap')) {
      path.replaceWith(getExportsIdentifier(asset));
    }
  },

  AssignmentExpression(path, asset) {
    let left = path.node.left;
    if (t.isIdentifier(left) && left.name === 'exports' && !path.scope.hasBinding('exports') && !path.scope.getData('shouldWrap')) {
      path.get('left').replaceWith(getExportsIdentifier(asset));
    }
  },

  UnaryExpression(path) {
    // Replace `typeof module` with "object"
    if (path.node.operator === 'typeof' && t.isIdentifier(path.node.argument) && TYPEOF[path.node.argument.name] && !path.scope.hasBinding(path.node.argument.name) && !path.scope.getData('shouldWrap')) {
      path.replaceWith(t.stringLiteral(TYPEOF[path.node.argument.name]));
    }
  },

  CallExpression(path, asset) {
    var _path$node = path.node;
    let callee = _path$node.callee,
        args = _path$node.arguments;


    let isRequire = t.isIdentifier(callee) && callee.name === 'require' && args.length === 1 && t.isStringLiteral(args[0]) && !path.scope.hasBinding('require');

    if (isRequire) {
      // Ignore require calls that were ignored earlier.
      if (!asset.dependencies.has(args[0].value)) {
        return;
      }

      // Generate a variable name based on the current asset id and the module name to require.
      // This will be replaced by the final variable name of the resolved asset in the packager.
      // path.replaceWith(getIdentifier(asset, 'require', args[0].value));
      path.replaceWith(REQUIRE_CALL_TEMPLATE({
        ID: t.numericLiteral(asset.id),
        SOURCE: t.stringLiteral(args[0].value)
      }));
    }

    let isRequireResolve = matchesPattern(callee, 'require.resolve') && args.length === 1 && t.isStringLiteral(args[0]) && !path.scope.hasBinding('require');

    if (isRequireResolve) {
      path.replaceWith(getIdentifier(asset, 'require_resolve', args[0].value));
    }
  },

  ImportDeclaration(path, asset) {
    // For each specifier, rename the local variables to point to the imported name.
    // This will be replaced by the final variable name of the resolved asset in the packager.
    var _iteratorNormalCompletion = true;
    var _didIteratorError = false;
    var _iteratorError = undefined;

    try {
      for (var _iterator = path.node.specifiers[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) {
        let specifier = _step.value;

        if (t.isImportDefaultSpecifier(specifier)) {
          path.scope.rename(specifier.local.name, getName(asset, 'import', path.node.source.value, 'default'));
        } else if (t.isImportSpecifier(specifier)) {
          path.scope.rename(specifier.local.name, getName(asset, 'import', path.node.source.value, specifier.imported.name));
        } else if (t.isImportNamespaceSpecifier(specifier)) {
          path.scope.rename(specifier.local.name, getName(asset, 'require', path.node.source.value));
        }
      }
    } catch (err) {
      _didIteratorError = true;
      _iteratorError = err;
    } finally {
      try {
        if (!_iteratorNormalCompletion && _iterator.return) {
          _iterator.return();
        }
      } finally {
        if (_didIteratorError) {
          throw _iteratorError;
        }
      }
    }

    path.remove();
  },

  ExportDefaultDeclaration(path, asset) {
    let declaration = path.node.declaration;

    let identifier = getIdentifier(asset, 'export', 'default');

    if (t.isIdentifier(declaration)) {
      // Rename the variable being exported.
      safeRename(path, declaration.name, identifier.name);
      path.remove();
    } else if (t.isExpression(declaration) || !declaration.id) {
      // Declare a variable to hold the exported value.
      path.replaceWith(t.variableDeclaration('var', [t.variableDeclarator(identifier, t.toExpression(declaration))]));
    } else {
      // Rename the declaration to the exported name.
      safeRename(path, declaration.id.name, identifier.name);
      path.replaceWith(declaration);
    }

    // Add assignment to exports object for namespace imports and commonjs.
    if (path.scope.hasGlobal('module') || path.scope.hasGlobal('exports')) {
      path.insertAfter(EXPORT_ASSIGN_TEMPLATE({
        EXPORTS: getExportsIdentifier(asset),
        NAME: t.identifier('default'),
        LOCAL: identifier
      }));
    }

    asset.cacheData.exports[identifier.name] = 'default';

    // Mark the asset as an ES6 module, so we handle imports correctly in the packager.
    asset.cacheData.isES6Module = true;
  },

  ExportNamedDeclaration(path, asset) {
    var _path$node2 = path.node;
    let declaration = _path$node2.declaration,
        source = _path$node2.source,
        specifiers = _path$node2.specifiers;


    if (source) {
      var _iteratorNormalCompletion2 = true;
      var _didIteratorError2 = false;
      var _iteratorError2 = undefined;

      try {
        for (var _iterator2 = specifiers[Symbol.iterator](), _step2; !(_iteratorNormalCompletion2 = (_step2 = _iterator2.next()).done); _iteratorNormalCompletion2 = true) {
          let specifier = _step2.value;

          let local, exported;

          if (t.isExportDefaultSpecifier(specifier)) {
            local = getIdentifier(asset, 'import', source.value, 'default');
            exported = specifier.exported;
          } else if (t.isExportNamespaceSpecifier(specifier)) {
            local = getIdentifier(asset, 'require', source.value);
            exported = specifier.exported;
          } else if (t.isExportSpecifier(specifier)) {
            local = getIdentifier(asset, 'import', source.value, specifier.local.name);
            exported = specifier.exported;
          }

          // Create a variable to re-export from the imported module.
          path.insertAfter(t.variableDeclaration('var', [t.variableDeclarator(getIdentifier(asset, 'export', exported.name), local)]));

          if (path.scope.hasGlobal('module') || path.scope.hasGlobal('exports')) {
            path.insertAfter(EXPORT_ASSIGN_TEMPLATE({
              EXPORTS: getExportsIdentifier(asset),
              NAME: t.identifier(exported.name),
              LOCAL: local
            }));
          }

          asset.cacheData.exports[getName(asset, 'export', exported.name)] = exported.name;
        }
      } catch (err) {
        _didIteratorError2 = true;
        _iteratorError2 = err;
      } finally {
        try {
          if (!_iteratorNormalCompletion2 && _iterator2.return) {
            _iterator2.return();
          }
        } finally {
          if (_didIteratorError2) {
            throw _iteratorError2;
          }
        }
      }

      path.remove();
    } else if (declaration) {
      path.replaceWith(declaration);

      let identifiers = t.getBindingIdentifiers(declaration);
      for (let id in identifiers) {
        addExport(asset, path, identifiers[id], identifiers[id]);
      }
    } else if (specifiers.length > 0) {
      var _iteratorNormalCompletion3 = true;
      var _didIteratorError3 = false;
      var _iteratorError3 = undefined;

      try {
        for (var _iterator3 = specifiers[Symbol.iterator](), _step3; !(_iteratorNormalCompletion3 = (_step3 = _iterator3.next()).done); _iteratorNormalCompletion3 = true) {
          let specifier = _step3.value;

          addExport(asset, path, specifier.local, specifier.exported);
        }
      } catch (err) {
        _didIteratorError3 = true;
        _iteratorError3 = err;
      } finally {
        try {
          if (!_iteratorNormalCompletion3 && _iterator3.return) {
            _iterator3.return();
          }
        } finally {
          if (_didIteratorError3) {
            throw _iteratorError3;
          }
        }
      }

      path.remove();
    }

    // Mark the asset as an ES6 module, so we handle imports correctly in the packager.
    asset.cacheData.isES6Module = true;
  },

  ExportAllDeclaration(path, asset) {
    asset.cacheData.wildcards.push(path.node.source.value);
    asset.cacheData.isES6Module = true;
    path.remove();
  }
};

function addExport(asset, path, local, exported) {
  // Check if this identifier has already been exported.
  // If so, create an export alias for it, otherwise, rename the local variable to an export.
  if (asset.cacheData.exports[local.name]) {
    asset.cacheData.exports[getName(asset, 'export', exported.name)] = asset.cacheData.exports[local.name];
  } else {
    asset.cacheData.exports[getName(asset, 'export', exported.name)] = exported.name;
    path.scope.rename(local.name, getName(asset, 'export', exported.name));
  }
}

function safeRename(path, from, to) {
  // If the binding that we're renaming is constant, it's safe to rename it.
  // Otherwise, create a new binding that references the original.
  let binding = path.scope.getBinding(from);
  if (binding && binding.constant) {
    path.scope.rename(from, to);
  } else {
    path.insertAfter(t.variableDeclaration('var', [t.variableDeclarator(t.identifier(to), t.identifier(from))]));
  }
}

function getName(asset, type, ...rest) {
  return '$' + asset.id + '$' + type + (rest.length ? '$' + rest.map(name => name === 'default' ? name : t.toIdentifier(name)).join('$') : '');
}

function getIdentifier(asset, type, ...rest) {
  return t.identifier(getName(asset, type, ...rest));
}

function getExportsIdentifier(asset) {
  return getIdentifier(asset, 'exports');
}