#!/usr/bin/env python3

# 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/.

import json
import logging
import os
import pathlib
import re
import subprocess
import sys
import argparse


def path_sep_to_native(path_str):
    """Make separators in the path OS native."""
    return os.sep.join(path_str.split("/"))


def path_sep_from_native(path):
    """Make separators in the path OS native."""
    return "/".join(str(path).split(os.sep))


excluded_from_convert_prefix = list(
    map(
        path_sep_to_native,
        [
            # Testcases for actors.
            "toolkit/actors/TestProcessActorChild.jsm",
            "toolkit/actors/TestProcessActorParent.jsm",
            "toolkit/actors/TestWindowChild.jsm",
            "toolkit/actors/TestWindowParent.jsm",
            "js/xpconnect/tests/unit/",
            # Testcase for build system.
            "python/mozbuild/mozbuild/test/",
        ],
    )
)


def is_excluded_from_convert(path):
    """Returns true if the JSM file shouldn't be converted to ESM."""
    path_str = str(path)
    for prefix in excluded_from_convert_prefix:
        if path_str.startswith(prefix):
            return True

    return False


excluded_from_imports_prefix = list(
    map(
        path_sep_to_native,
        [
            # Vendored or auto-generated files.
            "browser/components/pocket/content/panels/js/vendor.bundle.js",
            "devtools/client/debugger/dist/parser-worker.js",
            "devtools/client/debugger/test/mochitest/examples/react/build/main.js",
            "devtools/client/debugger/test/mochitest/examples/sourcemapped/polyfill-bundle.js",
            "devtools/client/inspector/markup/test/shadowdom_open_debugger.min.js",
            "devtools/client/shared/source-map-loader/test/browser/fixtures/bundle.js",
            "layout/style/test/property_database.js",
            "services/fxaccounts/FxAccountsPairingChannel.js",
            "testing/talos/talos/tests/devtools/addon/content/pages/custom/debugger/static/js/main.js",  # noqa E501
            "testing/web-platform/",
            # Unrelated testcases that has edge case syntax.
            "browser/components/sessionstore/test/unit/data/",
            "devtools/client/debugger/src/workers/parser/tests/fixtures/",
            "devtools/client/debugger/test/mochitest/examples/sourcemapped/fixtures/",
            "devtools/client/webconsole/test/browser/test-syntaxerror-worklet.js",
            "devtools/server/tests/xpcshell/test_framebindings-03.js",
            "devtools/server/tests/xpcshell/test_framebindings-04.js",
            "devtools/shared/tests/xpcshell/test_eventemitter_basic.js",
            "devtools/shared/tests/xpcshell/test_eventemitter_static.js",
            "dom/base/crashtests/module-with-syntax-error.js",
            "dom/base/test/file_bug687859-16.js",
            "dom/base/test/file_bug687859-16.js",
            "dom/base/test/file_js_cache_syntax_error.js",
            "dom/base/test/jsmodules/module_badSyntax.js",
            "dom/canvas/test/reftest/webgl-utils.js",
            "dom/encoding/test/file_utf16_be_bom.js",
            "dom/encoding/test/file_utf16_le_bom.js",
            "dom/html/test/bug649134/file_bug649134-1.sjs",
            "dom/html/test/bug649134/file_bug649134-2.sjs",
            "dom/media/webrtc/tests/mochitests/identity/idp-bad.js",
            "dom/serviceworkers/test/file_js_cache_syntax_error.js",
            "dom/serviceworkers/test/parse_error_worker.js",
            "dom/workers/test/importScripts_worker_imported3.js",
            "dom/workers/test/invalid.js",
            "dom/workers/test/threadErrors_worker1.js",
            "dom/xhr/tests/browser_blobFromFile.js",
            "image/test/browser/browser_image.js",
            "js/xpconnect/tests/chrome/test_bug732665_meta.js",
            "js/xpconnect/tests/mochitest/class_static_worker.js",
            "js/xpconnect/tests/unit/bug451678_subscript.js",
            "js/xpconnect/tests/unit/error_other.sys.mjs",
            "js/xpconnect/tests/unit/es6module_parse_error.js",
            "js/xpconnect/tests/unit/recursive_importA.jsm",
            "js/xpconnect/tests/unit/recursive_importB.jsm",
            "js/xpconnect/tests/unit/syntax_error.jsm",
            "js/xpconnect/tests/unit/test_defineModuleGetter.js",
            "js/xpconnect/tests/unit/test_import.js",
            "js/xpconnect/tests/unit/test_import_shim.js",
            "js/xpconnect/tests/unit/test_recursive_import.js",
            "js/xpconnect/tests/unit/test_unload.js",
            "modules/libpref/test/unit/data/testParser.js",
            "python/mozbuild/mozbuild/test/",
            "remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs",
            "testing/talos/talos/startup_test/sessionrestore/profile-manywindows/sessionstore.js",
            "testing/talos/talos/startup_test/sessionrestore/profile/sessionstore.js",
            "toolkit/components/reader/Readerable.sys.mjs",
            "toolkit/components/workerloader/tests/moduleF-syntax-error.js",
            "tools/lint/test/",
            "tools/update-packaging/test/",
            # SpiderMonkey internals.
            "js/examples/",
            "js/src/",
            # Files has macro.
            "browser/app/profile/firefox.js",
            "browser/branding/official/pref/firefox-branding.js",
            "browser/components/enterprisepolicies/schemas/schema.sys.mjs",
            "browser/locales/en-US/firefox-l10n.js",
            "mobile/android/app/geckoview-prefs.js",
            "mobile/android/app/mobile.js",
            "mobile/android/locales/en-US/mobile-l10n.js",
            "modules/libpref/greprefs.js",
            "modules/libpref/init/all.js",
            "testing/condprofile/condprof/tests/profile/user.js",
            "testing/mozbase/mozprofile/tests/files/prefs_with_comments.js",
            "toolkit/modules/AppConstants.sys.mjs",
            "toolkit/mozapps/update/tests/data/xpcshellConstantsPP.js",
            # Uniffi templates
            "toolkit/components/uniffi-bindgen-gecko-js/src/templates/js/",
            "node_modules/",
            "resource/tinymce/",
            "resource/react",
        ],
    )
)

EXCLUSION_FILES = []


def load_exclusion_files():
    for path in EXCLUSION_FILES:
        with open(path, "r") as f:
            for line in f:
                p = path_sep_to_native(re.sub(r"\*$", "", line.strip()))
                excluded_from_imports_prefix.append(p)


def is_excluded_from_imports(path):
    """Returns true if the JS file content shouldn't be handled by
    jscodeshift.

    This filter is necessary because jscodeshift cannot handle some
    syntax edge cases and results in unexpected rewrite."""
    path_str = str(path)
    for prefix in excluded_from_imports_prefix:
        if path_str.startswith(prefix):
            return True

    return False


# Wrapper for hg/git operations
class VCSUtils:
    def run(self, cmd):
        # Do not pass check=True because the pattern can match no file.
        lines = subprocess.run(cmd, stdout=subprocess.PIPE).stdout.decode()
        return filter(lambda x: x != "", lines.split("\n"))


class GitUtils(VCSUtils):
    def is_available():
        return pathlib.Path(".git").exists()

    def rename(self, before, after):
        cmd = ["git", "mv", before, after]
        subprocess.run(cmd, check=True)

    def find_jsms(self, path):
        jsms = []

        cmd = ["git", "ls-files", f"{path}/*.jsm"]
        for line in self.run(cmd):
            jsm = pathlib.Path(line)
            if is_excluded_from_convert(jsm):
                continue
            jsms.append(jsm)

        handled = {}
        cmd = ["git", "grep", r"EXPORTED_SYMBOLS = \[", f"{path}/*.js"]
        for line in self.run(cmd):
            m = re.search("^([^:]+):", line)
            if not m:
                continue
            filename = m.group(1)
            if filename in handled:
                continue
            handled[filename] = True
            jsm = pathlib.Path(filename)
            if is_excluded_from_convert(jsm):
                continue
            jsms.append(jsm)

        return jsms

    def find_all_jss(self, path):
        jss = []

        cmd = [
            "git",
            "ls-files",
            f"{path}/*.jsm",
            f"{path}/*.js",
            f"{path}/*.mjs",
            f"{path}/*.sjs",
        ]
        for line in self.run(cmd):
            js = pathlib.Path(line)
            if is_excluded_from_imports(js):
                continue
            jss.append(js)

        return jss


def esmify(path=None, convert=False, imports=False, prefix=""):
    class Summary:
        def __init__(self):
            self.convert_errors = []
            self.import_errors = []
            self.rename_errors = []
            self.no_refs = []

    class CommandContext:
        def log(self, level, tag, _, message):
            print(f"{tag}: {message}")
    command_context = CommandContext()
    
    """
    This command does the following 2 steps:
      1. Convert the JSM file specified by `path` to ESM file, or the JSM files
         inside the directory specified by `path` to ESM files, and also
         fix references in build files and test definitions
      2. Convert import calls inside file(s) specified by `path` for ESM-ified
         files to use new APIs

    Example 1:
      # Convert all JSM files inside `chrome/content/zotero/xpcom` directory,
      # and replace all references for ESM-ified files in the entire tree to use
      # new APIs

      $ python3 scripts/migrate-fx140/migrate.py esmify --convert chrome/content/zotero/xpcom
      $ python3 scripts/migrate-fx140/migrate.py esmify --imports . --prefix=chrome/content/zotero/xpcom

    Example 2:
      # Convert all JSM files inside `chrome` directory, and replace all
      # references for the JSM files inside `chrome` directory to use
      # new APIs

      $ python3 scripts/migrate-fx140/migrate.py esmify chrome
    """

    def error(text):
        command_context.log(logging.ERROR, "esmify", {}, f"[ERROR] {text}")

    def warn(text):
        command_context.log(logging.WARN, "esmify", {}, f"[WARN] {text}")

    def info(text):
        command_context.log(logging.INFO, "esmify", {}, f"[INFO] {text}")

    # If no options is specified, perform both.
    if not convert and not imports:
        convert = True
        imports = True

    path = pathlib.Path(path[0])

    if not verify_path(command_context, path):
        return 1

    if GitUtils.is_available():
        vcs_utils = GitUtils()
    else:
        error(
            "This script needs to be run inside a zotero-client Git clone."
        )
        return 1

    load_exclusion_files()

    is_single_file = path.is_file()

    modified_files = []
    summary = Summary()

    if convert:
        info("Searching files to convert to ESM...")
        if is_single_file:
            jsms = [path]
        else:
            jsms = vcs_utils.find_jsms(path)

        info(f"Found {len(jsms)} file(s) to convert to ESM.")

        info("Converting to ESM...")
        jsms = esmify_convert_module(jsms, summary)
        if jsms is None:
            error("Failed to rewrite exports.")
            return 1

        info("Renaming...")
        esms = rename_jsms(command_context, vcs_utils, jsms, summary)

        modified_files += esms

    if imports:
        info("Searching files to rewrite imports...")

        if is_single_file:
            if convert:
                # Already converted above
                jss = esms
            else:
                jss = [path]
        else:
            jss = vcs_utils.find_all_jss(path)

        info(f"Checking {len(jss)} JS file(s). Rewriting any matching imports...")

        result = esmify_rewrite_imports(jss, prefix, summary)
        if result is None:
            return 1

        info(f"Rewritten {len(result)} file(s).")

        # Only modified files needs eslint fix
        modified_files += result

    modified_files = list(set(modified_files))

    info(f"Applying eslint --fix for {len(modified_files)} file(s)...")
    eslint_fix(command_context, modified_files)

    def print_files(f, errors):
        for [path, message] in errors:
            f(f"  * {path}")
            if message:
                f(f"    {message}")

    if len(summary.convert_errors):
        error("========")
        error("The following files were not converted into ESM due to errors:")
        print_files(error, summary.convert_errors)

    if len(summary.import_errors):
        warn("========")
        warn("The following files were not rewritten to import ESMs due to errors:")
        warn(
            "(NOTE: Errors related to 'private names' are mostly due to "
            " preprocessor macros in the file):"
        )
        print_files(warn, summary.import_errors)

    if len(summary.rename_errors):
        error("========")
        error("The following files were not renamed due to errors:")
        print_files(error, summary.rename_errors)

    if len(summary.no_refs):
        warn("========")
        warn("The following files were not found in any build files.")
        warn("Please update references to these files manually:")
        print_files(warn, summary.rename_errors)

    return 0


def esmify_convert_module(jsms, summary):
    """Replace EXPORTED_SYMBOLS with export declarations, and replace
    ChromeUtils.importESModule with static import as much as possible,
    and return the list of successfully rewritten files."""

    if len(jsms) == 0:
        return []

    env = os.environ.copy()

    stdin = "\n".join(map(str, jsms)).encode()

    ok_files, errors = run_jscodeshift(
        "use-import-export-declarations.js",
        env=env,
        stdin=stdin,
    )

    if ok_files is None and errors is None:
        return None

    summary.convert_errors.extend(errors)

    return ok_files


def esmify_rewrite_imports(jss, prefix, summary):
    """Replace import calls for JSM with import calls for ESM or static import
    for ESM."""

    if len(jss) == 0:
        return []

    env = os.environ.copy()
    env["ESMIFY_TARGET_PREFIX"] = prefix

    stdin = "\n".join(map(str, jss)).encode()

    ok_files, errors = run_jscodeshift(
        "import-to-import_esmodule.js",
        env=env,
        stdin=stdin,
    )

    if ok_files is None and errors is None:
        return None

    summary.import_errors.extend(errors)

    return ok_files


def asyncify(path=None):
    class Summary:
        def __init__(self):
            self.convert_errors = []

    class CommandContext:
        def log(self, level, tag, _, message):
            print(f"{tag}: {message}")
    command_context = CommandContext()

    def error(text):
        command_context.log(logging.ERROR, "asyncify", {}, f"[ERROR] {text}")

    def warn(text):
        command_context.log(logging.WARN, "asyncify", {}, f"[WARN] {text}")

    def info(text):
        command_context.log(logging.INFO, "asyncify", {}, f"[INFO] {text}")

    path = pathlib.Path(path[0])

    if not verify_path(command_context, path):
        return 1

    if GitUtils.is_available():
        vcs_utils = GitUtils()
    else:
        error(
            "This script needs to be run inside a zotero-client Git clone."
        )
        return 1

    load_exclusion_files()

    is_single_file = path.is_file()

    summary = Summary()

    info("Searching files to rewrite Bluebird methods...")

    if is_single_file:
        jss = [path]
    else:
        jss = vcs_utils.find_all_jss(path)
    
    info(f"Checking {len(jss)} JS file(s). Rewriting Bluebird methods...")
    
    modified_files = asyncify_rewrite_bluebird_methods(jss, summary)
    if modified_files is None:
        return 1
    
    info(f"Rewritten {len(modified_files)} file(s).")

    info(f"Applying eslint --fix for {len(modified_files)} file(s)...")
    eslint_fix(command_context, modified_files)

    def print_files(f, errors):
        for [path, message] in errors:
            f(f"  * {path}")
            if message:
                f(f"    {message}")

    if len(summary.convert_errors):
        error("========")
        error("The following files were not migrated to use async due to errors:")
        print_files(error, summary.convert_errors)

    return 0


def asyncify_rewrite_bluebird_methods(jss, summary):
    if len(jss) == 0:
        return []

    env = os.environ.copy()
    stdin = "\n".join(map(str, jss)).encode()

    ok_files, errors = run_jscodeshift(
        "bluebird-to-async.js",
        env=env,
        stdin=stdin,
    )

    if ok_files is None and errors is None:
        return None

    summary.convert_errors.extend(errors)

    return ok_files


def verify_path(command_context, path):
    """Check if the path passed to the command is valid relative path."""

    def error(text):
        command_context.log(logging.ERROR, "migrate", {}, f"[ERROR] {text}")

    if not path.exists():
        error(f"{path} does not exist.")
        return False

    if path.is_absolute():
        error("Path must be a relative path from zotero-client checkout.")
        return False

    return True


def find_file(path, target):
    """Find `target` file in ancestor of path."""
    target_path = path.parent / target
    if not target_path.exists():
        if path.parent == path:
            return None

        return find_file(path.parent, target)

    return target_path


def try_rename_in(command_context, path, target, jsm_name, esm_name, jsm_path):
    """Replace the occurrences of `jsm_name` with `esm_name` in `target`
    file."""

    def info(text):
        command_context.log(logging.INFO, "esmify", {}, f"[INFO] {text}")

    if type(target) is str:
        # Target is specified by filename, that may exist somewhere in
        # the jsm's directory or ancestor directories.
        target_path = find_file(path, target)
        if not target_path:
            return False

        # JSM should be specified with relative path in the file.
        #
        # Single moz.build or jar.mn can contain multiple files with same name.
        # Search for relative path.
        jsm_relative_path = jsm_path.relative_to(target_path.parent)
        jsm_path_str = path_sep_from_native(str(jsm_relative_path))
    else:
        # Target is specified by full path.
        target_path = target

        # JSM should be specified with full path in the file.
        jsm_path_str = path_sep_from_native(str(jsm_path))

    jsm_path_re = re.compile(r"\b" + jsm_path_str.replace(".", r"\.") + r"\b")
    jsm_name_re = re.compile(r"\b" + jsm_name.replace(".", r"\.") + r"\b")

    modified = False
    content = ""
    with open(target_path, "r") as f:
        for line in f:
            if jsm_path_re.search(line):
                modified = True
                line = jsm_name_re.sub(esm_name, line)

            content += line

    if modified:
        info(f"  {str(target_path)}")
        info(f"    {jsm_name} => {esm_name}")
        with open(target_path, "w", newline="\n") as f:
            f.write(content)

    return True


def try_rename_uri_in(command_context, target, jsm_name, esm_name, jsm_uri, esm_uri):
    """Replace the occurrences of `jsm_uri` with `esm_uri` in `target` file."""

    def info(text):
        command_context.log(logging.INFO, "esmify", {}, f"[INFO] {text}")

    modified = False
    content = ""
    with open(target, "r") as f:
        for line in f:
            if jsm_uri in line:
                modified = True
                line = line.replace(jsm_uri, esm_uri)

            content += line

    if modified:
        info(f"  {str(target)}")
        info(f"    {jsm_name} => {esm_name}")
        with open(target, "w", newline="\n") as f:
            f.write(content)

    return True


def try_rename_components_conf(command_context, path, jsm_name, esm_name):
    """Replace the occurrences of `jsm_name` with `esm_name` in components.conf
    file."""

    def info(text):
        command_context.log(logging.INFO, "esmify", {}, f"[INFO] {text}")

    target_path = find_file(path, "components.conf")
    if not target_path:
        return False

    # Unlike try_rename_in, components.conf contains the URL instead of
    # relative path, and also there are no known files with same name.
    # Simply replace the filename.

    with open(target_path, "r") as f:
        content = f.read()

    prop_re = re.compile(
        "[\"']jsm[\"']:(.*)" + r"\b" + jsm_name.replace(".", r"\.") + r"\b"
    )

    if not prop_re.search(content):
        return False

    info(f"  {str(target_path)}")
    info(f"    {jsm_name} => {esm_name}")

    content = prop_re.sub(r"'esModule':\1" + esm_name, content)
    with open(target_path, "w", newline="\n") as f:
        f.write(content)

    return True


def esmify_name(name):
    return re.sub(r"\.(jsm|js|jsm\.js)$", ".mjs", name)


def esmify_path(jsm_path):
    jsm_name = jsm_path.name
    esm_name = re.sub(r"\.(jsm|js|jsm\.js)$", ".mjs", jsm_name)
    esm_path = jsm_path.parent / esm_name
    return esm_path


path_to_uri_map = None


def load_path_to_uri_map():
    global path_to_uri_map

    if path_to_uri_map:
        return

    if "ESMIFY_MAP_JSON" in os.environ:
        json_map = pathlib.Path(os.environ["ESMIFY_MAP_JSON"])
    else:
        json_map = pathlib.Path(__file__).parent / "map.json"

    with open(json_map, "r") as f:
        uri_to_path_map = json.loads(f.read())

    path_to_uri_map = dict()

    for uri, paths in uri_to_path_map.items():
        if type(paths) is str:
            paths = [paths]

        for path in paths:
            path_to_uri_map[path] = uri


def find_jsm_uri(jsm_path):
    load_path_to_uri_map()

    path = path_sep_from_native(jsm_path)

    if path in path_to_uri_map:
        return path_to_uri_map[path]

    return None


def rename_single_file(command_context, vcs_utils, jsm_path, summary):
    """Rename `jsm_path` to .mjs, and fix references to the file in build
    and test definitions."""

    def info(text):
        command_context.log(logging.INFO, "esmify", {}, f"[INFO] {text}")

    esm_path = esmify_path(jsm_path)

    jsm_name = jsm_path.name
    esm_name = esm_path.name

    target_files = []

    info(f"{jsm_path} => {esm_path}")

    renamed = False
    for target in target_files:
        if try_rename_in(
            command_context, jsm_path, target, jsm_name, esm_name, jsm_path
        ):
            renamed = True

    if try_rename_components_conf(command_context, jsm_path, jsm_name, esm_name):
        renamed = True

    uri_target_files = [
        pathlib.Path(
            "browser", "base", "content", "test", "performance", "browser_startup.js"
        ),
        pathlib.Path(
            "browser",
            "base",
            "content",
            "test",
            "performance",
            "browser_startup_content.js",
        ),
        pathlib.Path(
            "browser",
            "base",
            "content",
            "test",
            "performance",
            "browser_startup_content_subframe.js",
        ),
        pathlib.Path(
            "toolkit",
            "components",
            "backgroundtasks",
            "tests",
            "browser",
            "browser_xpcom_graph_wait.js",
        ),
    ]

    jsm_uri = find_jsm_uri(jsm_path)
    if jsm_uri:
        esm_uri = re.sub(r"\.(jsm|js|jsm\.js)$", ".sys.mjs", jsm_uri)

        for target in uri_target_files:
            if try_rename_uri_in(
                command_context, target, jsm_uri, esm_uri, jsm_name, esm_name
            ):
                renamed = True

    if not renamed:
        summary.no_refs.append([jsm_path, None])

    if not esm_path.exists():
        vcs_utils.rename(jsm_path, esm_path)
    else:
        summary.rename_errors.append([jsm_path, f"{esm_path} already exists"])

    return esm_path


def rename_jsms(command_context, vcs_utils, jsms, summary):
    esms = []
    for jsm in jsms:
        esm = rename_single_file(command_context, vcs_utils, jsm, summary)
        esms.append(esm)

    return esms


def run_jscodeshift(script, env, stdin):
    cmd = [
        "npx",
        "jscodeshift",
        "-t",
        str(npm_prefix / script),
        "--stdin",
        "--verbose=2",
        '--extensions=js,jsx,mjs,jsm'
    ]
    p = subprocess.Popen(cmd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
    p.stdin.write(stdin)
    p.stdin.close()

    ok_files = []
    errors = []
    while True:
        line = p.stdout.readline()
        if not line:
            break
        line = line.rstrip().decode()

        if line.startswith(" NOC "):
            continue

        print(line)

        m = re.search(r"^ (OKK|ERR) ([^ ]+)(?: (.+))?", line)
        if not m:
            continue

        result = m.group(1)
        path = pathlib.Path(m.group(2))
        error = m.group(3)

        if result == "OKK":
            ok_files.append(path)

        if result == "ERR":
            errors.append([path, error])

    if p.wait() != 0:
        return [None, None]

    return ok_files, errors


def eslint_fix(command_context, files):
    """Run ESLint with a very basic configuration to fix problems introduced by jscodeshift."""

    def info(text):
        command_context.log(logging.INFO, "migrate", {}, f"[INFO] {text}")

    if len(files) == 0:
        return

    args = [
        "npx",
        "eslint",
        "--no-config-lookup",
        # jscodeshift adds extra parentheses in `new function () {...}` expressions,
        # making them look like `new (function () {...})` -- undo that
        "--rule",
        "no-extra-parens: [error, functions]",
        # jscodeshift doesn't leave a space before the opening paren in a function
        "--rule",
        "space-before-function-paren: [warn, { anonymous: always, asyncArrow: always, named: never }]",
        "--fix",
    ]

    remaining = files[0:]

    # There can be too many files for single command line, perform by chunk.
    max_files = 16
    while len(remaining) > max_files:
        info(f"{len(remaining)} files remaining")

        chunk = remaining[0:max_files]
        remaining = remaining[max_files:]

        cmd = args + chunk
        # Don't check status code or print output -- zotero-client has many unfixable errors
        subprocess.run(cmd, stdout=subprocess.DEVNULL)

    info(f"{len(remaining)} files remaining")
    chunk = remaining
    cmd = args + chunk
    # Don't check status code or print output -- zotero-client has many unfixable errors
    subprocess.run(cmd, stdout=subprocess.DEVNULL)


npm_prefix = pathlib.Path("scripts") / "migrate-fx140"
path_from_npm_prefix = pathlib.Path("..") / ".."

if __name__ == "__main__":
    if not (npm_prefix / "node_modules").is_dir():
        cmd = [
            "npm",
            "install",
            "--prefix",
            str(npm_prefix),
        ]
        subprocess.run(cmd, check=True)

    parser = argparse.ArgumentParser()
    subparsers = parser.add_subparsers(required=True)
    
    parser_esmify = subparsers.add_parser("esmify")
    parser_esmify.set_defaults(func=esmify)
    parser_esmify.add_argument(
        "--convert",
        action="store_true",
        help="Only perform the step 1 = convert part",
    )
    parser_esmify.add_argument(
        "--imports",
        action="store_true",
        help="Only perform the step 2 = import calls part",
    )
    parser_esmify.add_argument(
        "--prefix",
        default="",
        help="Restrict the target of import in the step 2 to ESM-ified JSM, by the "
             "prefix match for the JSM file's path.  e.g. 'browser/'.",
    )

    parser_asyncify = subparsers.add_parser("asyncify")
    parser_asyncify.set_defaults(func=asyncify)

    parser.add_argument(
        "path",
        nargs=1,
        help="Path to the JSM file to migrate, or the directory that contains "
             "JSM files and/or JS files that reference ESM-ified JSM.",
    )

    args = parser.parse_args()
    func = args.func
    delattr(args, 'func')
    func(**vars(args))
