/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

// jscodeshift rule to replace import calls for JSM with import calls for ESM
// or static import for ESM.

/* eslint-env node */

const _path = require("path");
const { isESMified } = require(_path.resolve(__dirname, "./is-esmified.js"));
const {
  jsmExtPattern,
  esmifyExtension,
  isIdentifier,
  isString,
  warnForPath,
  getPrevStatement,
  getNextStatement,
  isMemberExpressionWithIdentifiers,
  rewriteMemberExpressionWithIdentifiers,
  createMemberExpressionWithIdentifiers,
} = require(_path.resolve(__dirname, "./utils.js"));
const {
  isImportESModuleCall,
  replaceImportESModuleCall,
  tryReplacingWithStaticImport,
} = require(_path.resolve(__dirname, "./static-import.js"));

module.exports = function (fileInfo, api) {
  const { jscodeshift } = api;
  
  // Cheap hack:
  // Zotero usually calls Components.utils.import(), not Cu.import().
  // Those are the same thing, but this script can't rewrite the former
  // to ChromeUtils.importESModule(), because it's unable to handle
  // changing the number of property accesses in the chain.
  // Just replace it in the text before parsing!
  fileInfo.source = fileInfo.source.replace(/Components\.utils\.import\(/g, 'Cu.import(');
  
  const root = jscodeshift(fileInfo.source);
  doTranslate(fileInfo.path, jscodeshift, root);
  return root.toSource({ useTabs: true, lineTerminator: "\n" });
};

module.exports.doTranslate = doTranslate;

function isESMifiedAndTarget(resourceURI) {
  const files = [];
  if (!isESMified(resourceURI, files)) {
    return false;
  }

  if ("ESMIFY_TARGET_PREFIX" in process.env) {
    const targetPrefix = process.env.ESMIFY_TARGET_PREFIX;
    for (const esm of files) {
      if (esm.startsWith(targetPrefix)) {
        return true;
      }
    }

    return false;
  }

  return true;
}

const importCalls = [
  {
    from: ["Cu", "import"],
    to: ["ChromeUtils", "importESModule"],
  },
  {
    from: ["ChromeUtils", "import"],
    to: ["ChromeUtils", "importESModule"],
  },
  {
    from: ["SpecialPowers", "ChromeUtils", "import"],
    to: ["SpecialPowers", "ChromeUtils", "importESModule"],
  },
];

const singleLazyGetterCalls = [
  {
    from: ["XPCOMUtils", "defineLazyModuleGetter"],
    to: ["ChromeUtils", "defineESModuleGetters"],
  },
  {
    from: ["ChromeUtils", "defineModuleGetter"],
    to: ["ChromeUtils", "defineESModuleGetters"],
  },
  {
    from: ["SpecialPowers", "ChromeUtils", "defineModuleGetter"],
    to: ["SpecialPowers", "ChromeUtils", "defineESModuleGetters"],
  },
];

const multiLazyGettersCalls = [
  {
    from: ["XPCOMUtils", "defineLazyModuleGetters"],
    to: ["ChromeUtils", "defineESModuleGetters"],
  },
];

function isMemberExpressionMatchingPatterns(node, patterns) {
  for (const item of patterns) {
    if (isMemberExpressionWithIdentifiers(node, item.from)) {
      return item;
    }
  }

  return null;
}

function replaceImportCall(inputFile, jscodeshift, path, rewriteItem) {
  if (path.node.arguments.length !== 1) {
    warnForPath(inputFile, path, `import call should have only one argument`);
    return;
  }

  const resourceURINode = path.node.arguments[0];
  if (!isString(resourceURINode)) {
    warnForPath(inputFile, path, `resource URI should be a string`);
    return;
  }

  const resourceURI = resourceURINode.value;
  if (!resourceURI.match(jsmExtPattern)) {
    warnForPath(inputFile, path, `Non-jsm: ${resourceURI}`);
    return;
  }

  if (!isESMifiedAndTarget(resourceURI)) {
    return;
  }

  if (
    !tryReplacingWithStaticImport(
      jscodeshift,
      inputFile,
      path,
      resourceURINode,
      false
    )
  ) {
    rewriteMemberExpressionWithIdentifiers(path.node.callee, rewriteItem.to);
    resourceURINode.value = esmifyExtension(resourceURI);
  }
}

// Find `ChromeUtils.defineESModuleGetters` or variant statement specified by
// expectedIDs, adjacent to `path` which uses the same target object.
function findDefineESModuleGettersStmt(path, expectedIDs) {
  // `path` must be top-level.
  if (path.parent.node.type !== "ExpressionStatement") {
    return null;
  }

  if (path.parent.parent.node.type !== "Program") {
    return null;
  }

  // Get previous or next statement with ChromeUtils.defineESModuleGetters.
  let callStmt;
  const prev = getPrevStatement(path.parent);
  if (
    prev &&
    prev.type === "ExpressionStatement" &&
    prev.expression.type === "CallExpression" &&
    isMemberExpressionWithIdentifiers(prev.expression.callee, expectedIDs)
  ) {
    callStmt = prev;
  } else {
    const next = getNextStatement(path.parent);
    if (
      next &&
      next.type === "ExpressionStatement" &&
      next.expression.type === "CallExpression" &&
      isMemberExpressionWithIdentifiers(next.expression.callee, expectedIDs)
    ) {
      callStmt = next;
    } else {
      return null;
    }
  }

  const call = callStmt.expression;

  if (call.arguments.length !== 2) {
    return null;
  }

  const modulesNode = call.arguments[1];
  if (modulesNode.type !== "ObjectExpression") {
    return null;
  }

  // Check if the target object is same.
  if (
    path.node.arguments[0].type === "ThisExpression" &&
    call.arguments[0].type === "ThisExpression"
  ) {
    return callStmt;
  }

  if (
    path.node.arguments[0].type === "Identifier" &&
    call.arguments[0].type === "Identifier" &&
    path.node.arguments[0].name === call.arguments[0].name
  ) {
    return callStmt;
  }

  return null;
}

function getPropKeyString(prop) {
  if (prop.key.type === "Identifier") {
    return prop.key.name;
  }

  if (prop.key.type === "Literal") {
    return prop.key.value.toString();
  }

  return "";
}

function sortProps(obj) {
  obj.properties.sort((a, b) => {
    return getPropKeyString(a) < getPropKeyString(b) ? -1 : 1;
  });
}

// Move comments above `nodeFrom` before `nodeTo`.
function moveComments(nodeTo, nodeFrom) {
  if (!nodeFrom.comments) {
    return;
  }
  if (nodeTo.comments) {
    nodeTo.comments = [...nodeTo.comments, ...nodeFrom.comments];
  } else {
    nodeTo.comments = nodeFrom.comments;
  }
  nodeFrom.comments = [];
}

function replaceLazyGetterCall(inputFile, jscodeshift, path, rewriteItem) {
  if (path.node.arguments.length !== 3) {
    warnForPath(inputFile, path, `lazy getter call should have 3 arguments`);
    return;
  }

  const nameNode = path.node.arguments[1];
  if (!isString(nameNode)) {
    warnForPath(inputFile, path, `name should be a string`);
    return;
  }

  const resourceURINode = path.node.arguments[2];
  if (!isString(resourceURINode)) {
    warnForPath(inputFile, path, `resource URI should be a string`);
    return;
  }

  const resourceURI = resourceURINode.value;
  if (!resourceURI.match(jsmExtPattern)) {
    warnForPath(inputFile, path, `Non-js/jsm: ${resourceURI}`);
    return;
  }

  if (!isESMifiedAndTarget(resourceURI)) {
    return;
  }

  resourceURINode.value = esmifyExtension(resourceURI);
  const prop = jscodeshift.property(
    "init",
    jscodeshift.identifier(nameNode.value),
    resourceURINode
  );

  const callStmt = findDefineESModuleGettersStmt(path, rewriteItem.to);
  if (callStmt) {
    // Move a property to existing ChromeUtils.defineESModuleGetters call.

    moveComments(callStmt, path.parent.node);
    path.parent.prune();

    callStmt.expression.arguments[1].properties.push(prop);
    sortProps(callStmt.expression.arguments[1]);
  } else {
    // Convert this call into ChromeUtils.defineESModuleGetters.

    rewriteMemberExpressionWithIdentifiers(path.node.callee, rewriteItem.to);
    path.node.arguments = [
      path.node.arguments[0],
      jscodeshift.objectExpression([prop]),
    ];
  }
}

function replaceLazyGettersCall(inputFile, jscodeshift, path, rewriteItem) {
  if (path.node.arguments.length !== 2) {
    warnForPath(inputFile, path, `lazy getters call should have 2 arguments`);
    return;
  }

  const modulesNode = path.node.arguments[1];
  if (modulesNode.type !== "ObjectExpression") {
    warnForPath(inputFile, path, `modules parameter should be an object`);
    return;
  }

  const esmProps = [];
  const jsmProps = [];

  for (const prop of modulesNode.properties) {
    const resourceURINode = prop.value;
    if (!isString(resourceURINode)) {
      warnForPath(inputFile, path, `resource URI should be a string`);
      jsmProps.push(prop);
      continue;
    }

    const resourceURI = resourceURINode.value;
    if (!resourceURI.match(jsmExtPattern)) {
      warnForPath(inputFile, path, `Non-js/jsm: ${resourceURI}`);
      jsmProps.push(prop);
      continue;
    }

    if (!isESMifiedAndTarget(resourceURI)) {
      jsmProps.push(prop);
      continue;
    }

    esmProps.push(prop);
  }

  if (esmProps.length === 0) {
    return;
  }

  let callStmt = findDefineESModuleGettersStmt(path, rewriteItem.to);
  if (jsmProps.length === 0) {
    if (callStmt) {
      // Move all properties to existing ChromeUtils.defineESModuleGetters call.

      moveComments(callStmt, path.parent.node);
      path.parent.prune();

      for (const prop of esmProps) {
        const resourceURINode = prop.value;
        resourceURINode.value = esmifyExtension(resourceURINode.value);
        callStmt.expression.arguments[1].properties.push(prop);
      }
      sortProps(callStmt.expression.arguments[1]);
    } else {
      // Convert this call into ChromeUtils.defineESModuleGetters.

      rewriteMemberExpressionWithIdentifiers(path.node.callee, rewriteItem.to);
      for (const prop of esmProps) {
        const resourceURINode = prop.value;
        resourceURINode.value = esmifyExtension(resourceURINode.value);
      }
    }
  } else {
    // Move some properties to ChromeUtils.defineESModuleGetters.

    if (path.parent.node.type !== "ExpressionStatement") {
      warnForPath(inputFile, path, `lazy getters call in unexpected context`);
      return;
    }

    if (!callStmt) {
      callStmt = jscodeshift.expressionStatement(
        jscodeshift.callExpression(
          createMemberExpressionWithIdentifiers(jscodeshift, rewriteItem.to),
          [path.node.arguments[0], jscodeshift.objectExpression([])]
        )
      );
      path.parent.insertBefore(callStmt);
    }

    moveComments(callStmt, path.parent.node);

    for (const prop of esmProps) {
      const resourceURINode = prop.value;
      resourceURINode.value = esmifyExtension(resourceURINode.value);
      callStmt.expression.arguments[1].properties.push(prop);
    }
    sortProps(callStmt.expression.arguments[1]);

    path.node.arguments[1].properties = jsmProps;
  }
}

function getProp(obj, key) {
  if (obj.type !== "ObjectExpression") {
    return null;
  }

  for (const prop of obj.properties) {
    if (prop.computed) {
      continue;
    }

    if (!prop.key) {
      continue;
    }

    if (isIdentifier(prop.key, key)) {
      return prop;
    }
  }

  return null;
}

function tryReplaceActorDefinition(inputFile, path, name) {
  const obj = path.node;

  const prop = getProp(obj, name);
  if (!prop) {
    return;
  }

  const moduleURIProp = getProp(prop.value, "moduleURI");
  if (!moduleURIProp) {
    return;
  }

  if (!isString(moduleURIProp.value)) {
    warnForPath(inputFile, path, `${name} moduleURI should be a string`);
    return;
  }

  const moduleURI = moduleURIProp.value.value;
  if (!moduleURI.match(jsmExtPattern)) {
    warnForPath(inputFile, path, `${name} Non-js/jsm: ${moduleURI}`);
    return;
  }

  if (!isESMifiedAndTarget(moduleURI)) {
    return;
  }

  moduleURIProp.key.name = "esModuleURI";
  moduleURIProp.value.value = esmifyExtension(moduleURI);
}

function doTranslate(inputFile, jscodeshift, root) {
  root.find(jscodeshift.CallExpression).forEach(path => {
    if (isImportESModuleCall(path.node)) {
      replaceImportESModuleCall(inputFile, jscodeshift, path, false);
      return;
    }

    const callee = path.node.callee;

    let item;
    item = isMemberExpressionMatchingPatterns(callee, importCalls);
    if (item) {
      replaceImportCall(inputFile, jscodeshift, path, item);
      return;
    }

    item = isMemberExpressionMatchingPatterns(callee, singleLazyGetterCalls);
    if (item) {
      replaceLazyGetterCall(inputFile, jscodeshift, path, item);
      return;
    }

    item = isMemberExpressionMatchingPatterns(callee, multiLazyGettersCalls);
    if (item) {
      replaceLazyGettersCall(inputFile, jscodeshift, path, item);
    }
  });

  root.find(jscodeshift.ObjectExpression).forEach(path => {
    tryReplaceActorDefinition(inputFile, path, "parent");
    tryReplaceActorDefinition(inputFile, path, "child");
  });
}
