"""Utility command runner."""

import argparse
import logging
import json
import os
import platform
import re
import requests
import shutil
import subprocess
from subprocess import check_call
import sys
import time

from codegen import perform_codegen, lint_code, reformat_code


LOGGER = logging.getLogger(__name__)
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
NODE_ROOT = os.path.join(PROJECT_ROOT, "js")
NODE_MODULES = os.path.join(NODE_ROOT, "node_modules")
WIDGET_TARGETS = [
    os.path.join(PROJECT_ROOT, "plotly", "package_data", "widgetbundle.js"),
]

NPM_PATH = os.pathsep.join(
    [
        os.path.join(NODE_ROOT, "node_modules", ".bin"),
        os.environ.get("PATH", os.defpath),
    ]
)


def plotly_js_version():
    """Load plotly.js version from js/package.json."""

    path = os.path.join(PROJECT_ROOT, "js", "package.json")
    with open(path, "rt") as f:
        package_json = json.load(f)
        version = package_json["dependencies"]["plotly.js"]
        version = version.replace("^", "")

    return version


def install_js_deps(local, build=True):
    """Install package.json dependencies using npm.

    When ``build`` is True (the default), also runs ``npm run build`` to
    rebuild the JupyterLab extension and FigureWidget bundles and verifies
    that the widget bundle exists. Pass ``build=False`` when you only need
    to refresh ``node_modules`` / ``package-lock.json`` (e.g. after a
    plotly.js version bump) and don't need the bundles rebuilt.
    """

    npmName = "npm"
    if platform.system() == "Windows":
        npmName = "npm.cmd"

    try:
        check_call([npmName, "--version"])
        has_npm = True
    except Exception:
        has_npm = False

    skip_npm = os.environ.get("SKIP_NPM", False)
    if skip_npm:
        LOGGER.info("Skipping npm-installation")
        return

    if not has_npm:
        LOGGER.error(
            "`npm` unavailable.  If you're running this command using sudo, make sure `npm` is available to sudo"
        )

    env = os.environ.copy()
    env["PATH"] = NPM_PATH

    if has_npm:
        LOGGER.info("Installing build dependencies with npm.  This may take a while...")
        check_call(
            [npmName, "install"],
            cwd=NODE_ROOT,
            stdout=sys.stdout,
            stderr=sys.stderr,
        )
        if local is not None:
            plotly_archive = os.path.join(local, "plotly.js.tgz")
            check_call(
                [npmName, "install", plotly_archive],
                cwd=NODE_ROOT,
                stdout=sys.stdout,
                stderr=sys.stderr,
            )
        if build:
            check_call(
                [npmName, "run", "build"],
                cwd=NODE_ROOT,
                stdout=sys.stdout,
                stderr=sys.stderr,
            )
        os.utime(NODE_MODULES, None)

    if build:
        for target in WIDGET_TARGETS:
            if not os.path.exists(target):
                msg = "Missing file: %s" % target
                raise ValueError(msg)


def overwrite_schema_local(uri):
    """Replace plot-schema.json with local copy."""

    path = os.path.join(PROJECT_ROOT, "codegen", "resources", "plot-schema.json")
    shutil.copyfile(uri, path)


def overwrite_schema(url):
    """Replace plot-schema.json with web copy."""

    req = requests.get(url)
    assert req.status_code == 200
    path = os.path.join(PROJECT_ROOT, "codegen", "resources", "plot-schema.json")
    with open(path, "wb") as f:
        f.write(req.content)


def overwrite_bundle_local(uri):
    """Replace minified JavaScript bundle.json with local copy."""

    path = os.path.join(PROJECT_ROOT, "plotly", "package_data", "plotly.min.js")
    shutil.copyfile(uri, path)


def overwrite_bundle(url):
    """Replace minified JavaScript bundle.json with web copy."""

    req = requests.get(url)
    print("url:", url)
    assert req.status_code == 200
    path = os.path.join(PROJECT_ROOT, "plotly", "package_data", "plotly.min.js")
    with open(path, "wb") as f:
        f.write(req.content)


def overwrite_plotlyjs_version_file(plotlyjs_version):
    """Replace plotly.js version file."""

    path = os.path.join(PROJECT_ROOT, "plotly", "offline", "_plotlyjs_version.py")
    with open(path, "w") as f:
        f.write(
            """\
# DO NOT EDIT
# This file is generated by the updatebundle commands.py command
__plotlyjs_version__ = "{plotlyjs_version}"
""".format(plotlyjs_version=plotlyjs_version)
        )


def get_latest_commit_info(repo, branch):
    """Get latest commit info from GitHub API."""

    url = "https://api.github.com/repos/{repo}/commits/{branch}".format(
        repo=repo, branch=branch
    )
    req = requests.get(url)
    assert req.status_code == 200, "Failed to fetch commit info: %s" % req.text
    commit = req.json()

    return {
        "vcs_revision": commit["sha"],
        "committer_date": commit["commit"]["committer"]["date"],
    }


def get_bundle_schema_local(local):
    """Get paths to local build files."""

    plotly_archive = os.path.join(local, "plotly.js.tgz")
    plotly_bundle = os.path.join(local, "dist/plotly.min.js")
    plotly_schemas = os.path.join(local, "dist/plot-schema.json")
    return plotly_archive, plotly_bundle, plotly_schemas


def get_github_urls(repo, revision):
    """Get URLs for required files from GitHub."""

    raw = f"https://raw.githubusercontent.com/{repo}/{revision}"
    archive_url = f"https://github.com/{repo}/tarball/{revision}"
    bundle_url = raw + "/dist/plotly.min.js"
    schema_url = raw + "/dist/plot-schema.json"

    return archive_url, bundle_url, schema_url


def update_schema(plotly_js_version):
    """Download latest version of the plot-schema JSON file."""

    url = (
        "https://raw.githubusercontent.com/plotly/plotly.js/"
        "v%s/dist/plot-schema.json" % plotly_js_version
    )
    overwrite_schema(url)


def update_bundle(plotly_js_version):
    """Download latest version of the plotly.js bundle."""

    url = (
        "https://raw.githubusercontent.com/plotly/plotly.js/"
        "v%s/dist/plotly.min.js" % plotly_js_version
    )
    overwrite_bundle(url)

    # Write plotly.js version file
    plotlyjs_version = plotly_js_version
    overwrite_plotlyjs_version_file(plotlyjs_version)


def update_plotlyjs(plotly_js_version, outdir):
    """Update project to a new version of plotly.js."""

    update_bundle(plotly_js_version)
    update_schema(plotly_js_version)
    perform_codegen(outdir)
    install_js_deps(local=None, build=False)


# FIXME: switch to argparse
def update_schema_bundle_from_master(args):
    """Update the plotly.js schema and bundle from master."""
    if args.local is None:
        build_info = get_latest_commit_info(args.devrepo, args.devbranch)
        archive_url, bundle_url, schema_url = get_github_urls(
            args.devrepo, build_info["vcs_revision"]
        )

        # Update bundle in package data
        overwrite_bundle(bundle_url)

        # Update schema in package data
        overwrite_schema(schema_url)
    else:
        # this info could be more informative but it doesn't seem as
        # useful in a local context and requires dependencies and
        # programming.
        build_info = {"vcs_revision": "local", "committer_date": str(time.time())}
        args.devrepo = args.local
        args.devbranch = ""

        archive_uri, bundle_uri, schema_uri = get_bundle_schema_local(args.local)
        overwrite_bundle_local(bundle_uri)
        overwrite_schema_local(schema_uri)

    # Update plotly.js url in package.json
    package_json_path = os.path.join(NODE_ROOT, "package.json")
    with open(package_json_path, "r") as f:
        package_json = json.load(f)

    # Replace version with bundle url
    package_json["dependencies"]["plotly.js"] = (
        archive_url if args.local is None else archive_uri
    )
    with open(package_json_path, "w") as f:
        json.dump(package_json, f, indent=2)

    # update plotly.js version in _plotlyjs_version
    rev = build_info["vcs_revision"]
    date = str(build_info["committer_date"])
    version = "_".join([args.devrepo, args.devbranch, date[:10], rev[:8]])
    overwrite_plotlyjs_version_file(version)
    install_js_deps(args.local)


def update_plotlyjs_dev(args, outdir):
    """Update project to a new development version of plotly.js."""

    update_schema_bundle_from_master(args)
    perform_codegen(outdir)


def bump_version_pyproject_toml(new_version):
    """
    Bump the version in pyproject.toml to new_version
    Returns True on success, False on failure
    """
    pyproject_toml_path = "pyproject.toml"
    pattern = r'(^\s*version\s*=\s*")([\w.]+)"(\s*$)'
    with open(pyproject_toml_path, "r") as f:
        content = f.read()
    new_content, n_subs = re.subn(
        pattern,
        rf'\g<1>{new_version}"\g<3>',
        content,
        count=1,  # replace only the first match
        flags=re.MULTILINE,
    )
    if n_subs < 1:
        print(
            f"FAILED to update version in {pyproject_toml_path}.",
            "Please update manually and then run `uv lock`.",
        )
        return False

    with open(pyproject_toml_path, "w") as f:
        f.write(new_content)

    # Run `uv lock` to update the version number in the `uv.lock` file (do not update manually)
    subprocess.run(["uv", "lock"], check=True)

    print(
        f"SUCCESS: Updated version in {pyproject_toml_path} to {new_version},",
        "and updated uv lockfile",
    )
    return True


def bump_version_package_json(new_version):
    """
    Bump the version in package.json to new_version
    Returns True on success, False on failure
    """
    js_dir = "js"
    subprocess.run(
        ["npm", "version", new_version, "--no-git-tag-version", "--allow-same-version"],
        cwd=js_dir,
        check=True,
    )
    print(
        f"SUCCESS: Updated version in {js_dir}/package.json and {js_dir}/package-lock.json to {new_version}"
    )

    # After modifying files in js/, we need to rebuild the JupyterLab extension
    print("Rebuilding JupyterLab extension (this may take a few seconds)...")
    subprocess.run(
        ["npm", "run", "build:labextension"],
        cwd=js_dir,
        check=True,
        stdout=subprocess.DEVNULL,  # Suppress stdout and stderr to avoid terminal clutter
        stderr=subprocess.DEVNULL,
    )
    print(f"SUCCESS: Updated JupyterLab extension files in plotly/labextension")

    return True


def bump_version_citation_cff(new_version, new_date):
    """
    Bump the version in CITATION.cff to new_version and date-released to new_date
    Returns True on success, False on failure
    """
    citation_cff_path = "CITATION.cff"
    pattern_version = r"(^\s*version\s*:\s*)([\w.]+)(\s*$)"
    pattern_date = r"(^\s*date-released\s*:\s*)([0-9\-]+)(\s*$)"

    with open(citation_cff_path, "r") as f:
        content = f.read()
    new_content, n_subs = re.subn(
        pattern_version,
        rf"\g<1>{new_version}\g<3>",
        content,
        count=1,  # replace only the first match
        flags=re.MULTILINE,
    )
    if n_subs < 1:
        print(
            f"FAILED to update version in {citation_cff_path}.",
            "Please update manually.",
        )
        return False
    new_content, n_subs = re.subn(
        pattern_date,
        rf"\g<1>{new_date}\g<3>",
        new_content,
        count=1,  # replace only the first match
        flags=re.MULTILINE,
    )
    if n_subs < 1:
        print(
            f"FAILED to update date-released in {citation_cff_path}.",
            "Please update manually.",
        )
        return False

    with open(citation_cff_path, "w") as f:
        f.write(new_content)
    print(
        f"SUCCESS: Updated version in {citation_cff_path} to {new_version}",
        f"and date-released to {new_date}",
    )
    return True


def bump_version_changelog_md(new_version, new_date):
    """
    Bump the version in CHANGELOG.md to new_version and date to new_date
    Returns True on success, False on failure
    """
    changelog_md_path = "CHANGELOG.md"
    pattern = r"(^\s*##\s*Unreleased\s*$)"
    new_header = f"\n\n## [{new_version}] - {new_date}\n"

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

    # Check if the header already exists, so that we don't add a double header
    already_exists_pattern = rf"(^\s*##\s*\[ *{re.escape(new_version)} *\])"
    if re.search(already_exists_pattern, content, flags=re.MULTILINE):
        print(
            f"Header for version {new_version} already exists",
            f"in {changelog_md_path}.",
        )
        return True

    new_content, n_subs = re.subn(
        pattern,
        rf"\g<1>{new_header}",
        content,
        count=1,  # replace only the first match
        flags=re.MULTILINE,
    )
    if n_subs < 1:
        print(
            f"FAILED to update version in {changelog_md_path}.",
            "Please update manually.",
        )
        return False

    with open(changelog_md_path, "w") as f:
        f.write(new_content)
    print(
        f"Added header in {changelog_md_path} with version {new_version}",
        f"and release date {new_date}",
    )
    return True


def bump_version(args):
    """Bump the version of plotly.py everywhere it needs to be updated."""
    new_version = args.version
    new_date = time.strftime("%Y-%m-%d")

    success = True

    success = success and bump_version_citation_cff(new_version, new_date)
    success = success and bump_version_changelog_md(new_version, new_date)
    success = success and bump_version_package_json(new_version)
    # Do this one last since it's the most visible,
    # so that if one of the above commands fails it will be easier to notice
    success = success and bump_version_pyproject_toml(new_version)

    if not success:
        print(
            "\n\n*** FAILED to update version for at least one file.",
            "Please check the output above for details. ***\n\n",
        )
        exit(1)

    print(
        f"\n\n*** SUCCESSFULLY updated version to {new_version}.",
        "Please verify the result using `git diff`. ***\n\n",
    )


def make_parser():
    """Make argument parser."""

    parser = argparse.ArgumentParser()
    subparsers = parser.add_subparsers(dest="cmd", help="Available subcommands")

    p_codegen = subparsers.add_parser("codegen", help="generate code")
    p_codegen.add_argument(
        "--noformat", action="store_true", help="prevent reformatting"
    )

    subparsers.add_parser("lint", help="lint code")

    subparsers.add_parser("format", help="reformat code")

    p_update_dev = subparsers.add_parser(
        "updateplotlyjsdev", help="update plotly.js for development"
    )
    p_update_dev.add_argument(
        "--devrepo", default="plotly/plotly.js", help="repository"
    )
    p_update_dev.add_argument("--devbranch", default="master", help="branch")
    p_update_dev.add_argument("--local", default=None, help="local path")

    subparsers.add_parser("updateplotlyjs", help="update plotly.js")

    p_bump_version = subparsers.add_parser("bumpversion", help="bump plotly.py version")
    # Add a positional argument for the version
    p_bump_version.add_argument("version", help="version number")

    return parser


def parse_args(parser: argparse.ArgumentParser):
    """Parse command line arguments."""
    return parser.parse_args()


def main():
    """Main driver."""

    project_root = os.path.dirname(os.path.realpath(__file__))
    outdir = os.path.join(project_root, "plotly")

    parser = make_parser()
    args = parse_args(parser)

    if args.cmd == "codegen":
        perform_codegen(outdir, noformat=args.noformat)

    elif args.cmd == "format":
        reformat_code(outdir)

    elif args.cmd == "lint":
        lint_code(outdir)

    elif args.cmd == "updateplotlyjsdev":
        update_plotlyjs_dev(args, outdir)

    elif args.cmd == "updateplotlyjs":
        version = plotly_js_version()
        print(version)
        update_plotlyjs(version, outdir)

    elif args.cmd == "bumpversion":
        bump_version(args)

    elif args.cmd is None:
        parser.print_help()
        sys.exit(1)

    else:
        print(f"unknown command {args.cmd}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()
