#!/usr/bin/env python3

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.
# ruff: noqa: E501, RUF005

import argparse
import getpass
import grp
import inspect
import json
import multiprocessing
import os
import platform
import random
import shutil
import string
import subprocess
import sys
import textwrap
import typing
from collections.abc import Callable
from pathlib import Path
from typing import Any

REPO_ROOT = Path(__file__).resolve().parent.parent.parent
SCRIPT_DIR = REPO_ROOT / ".ci-py-scripts"
NPROC = multiprocessing.cpu_count()


class col:
    BLUE = "\033[94m"
    CYAN = "\033[96m"
    GREEN = "\033[92m"
    YELLOW = "\033[93m"
    RED = "\033[91m"
    RESET = "\033[0m"
    BOLD = "\033[1m"
    UNDERLINE = "\033[4m"


def print_color(color: str, msg: str, bold: bool, **kwargs: Any) -> None:
    if hasattr(sys.stdout, "isatty") and sys.stdout.isatty():
        bold_code = col.BOLD if bold else ""
        print(bold_code + color + msg + col.RESET, **kwargs)
    else:
        print(msg, **kwargs)


warnings: list[str] = []


def clean_exit(msg: str) -> None:
    print_color(col.RED, msg, bold=True, file=sys.stderr)

    for warning in warnings:
        print_color(col.YELLOW, warning, bold=False, file=sys.stderr)

    exit(1)


def cmd(commands: list[Any], **kwargs: Any):
    commands = [str(s) for s in commands]
    command_str = " ".join(commands)
    print_color(col.BLUE, command_str, bold=True)
    proc = subprocess.run(commands, **kwargs)
    if proc.returncode != 0:
        raise RuntimeError(f"Command failed: '{command_str}'")
    return proc


def get_build_dir(name: str) -> str:
    build_dir = REPO_ROOT / f"build-{name}"
    return str(build_dir.relative_to(REPO_ROOT))


def check_docker():
    executable = shutil.which("docker")
    if executable is None:
        clean_exit("'docker' executable not found, install it first (e.g. 'apt install docker.io')")

    if sys.platform == "linux":
        # Check that the user is in the docker group before running
        try:
            group = grp.getgrnam("docker")
            if getpass.getuser() not in group.gr_mem:
                warnings.append(
                    f"Note: User '{getpass.getuser()}' is not in the 'docker' group, either:\n"
                    " * run with 'sudo'\n"
                    " * add user to 'docker': sudo usermod -aG docker $(whoami), then log out and back in",
                )
        except KeyError:
            warnings.append("Note: 'docker' group does not exist")


def check_gpu():
    if not (sys.platform == "linux" and shutil.which("lshw")):
        # Can't check GPU on non-Linux platforms
        return

    # See if we can check if a GPU is present in case of later failures,
    # but don't block on execution since this isn't critical
    try:
        proc = cmd(
            ["lshw", "-json", "-C", "display"],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            encoding="utf-8",
        )
        stdout = proc.stdout.strip().strip(",")
        stdout = json.loads(stdout)
    except (subprocess.CalledProcessError, json.decoder.JSONDecodeError):
        # Do nothing if any step failed
        return

    if isinstance(stdout, dict):
        # Sometimes lshw outputs a single item as a dict instead of a list of
        # dicts, so wrap it up if necessary
        stdout = [stdout]
    if not isinstance(stdout, list):
        return

    vendors = [s.get("vendor", "").lower() for s in stdout]
    if not any("nvidia" in vendor for vendor in vendors):
        warnings.append(
            "nvidia GPU not found in 'lshw', maybe use --cpu flag when running 'docs' command?"
        )


def gen_name(s: str) -> str:
    # random 4 letters
    suffix = "".join([random.choice(string.ascii_lowercase) for i in range(5)])
    return f"{s}-{suffix}"


def docker(
    name: str,
    image: str,
    scripts: list[str],
    env: dict[str, str],
    interactive: bool,
    additional_flags: dict[str, str] | None = None,
):
    """
    Invoke a set of bash scripts through docker/bash.sh

    name: container name
    image: docker image name
    scripts: list of bash commands to run
    env: environment to set
    """
    check_docker()

    # As sccache is added to these images these can be uncommented
    sccache_images = {
        "ci_gpu",
        "ci_cpu",
        # "ci_wasm",
        "ci_cortexm",
        "ci_arm",
        "ci_riscv",
        "ci_adreno",
    }

    if image in sccache_images and os.getenv("USE_SCCACHE", "1") == "1":
        scripts = [
            "sccache --start-server",
        ] + scripts
        # Set the C/C++ compiler so CMake picks them up in the build
        env["CC"] = "/opt/sccache/cc"
        env["CXX"] = "/opt/sccache/c++"
        env["SCCACHE_CACHE_SIZE"] = os.getenv("SCCACHE_CACHE_SIZE", "50G")
        env["SCCACHE_SERVER_PORT"] = os.getenv("SCCACHE_SERVER_PORT", "4226")

    env["PLATFORM"] = name

    docker_bash = REPO_ROOT / "docker" / "bash.sh"

    command = [docker_bash]
    if sys.stdout.isatty():
        command.append("-t")

    command.append("--name")
    command.append(name)
    if interactive:
        command.append("-i")
        scripts = ["interact() {", "  bash", "}", "trap interact 0", ""] + scripts

    for key, value in env.items():
        command.append("--env")
        command.append(f"{key}={value}")

    if additional_flags is not None:
        for key, value in additional_flags.items():
            command.append(key)
            command.append(value)

    SCRIPT_DIR.mkdir(exist_ok=True)

    script_file = SCRIPT_DIR / f"{name}.sh"
    with open(script_file, "w") as f:
        f.write("set -eux\n\n")
        f.write("\n".join(scripts))
        f.write("\n")

    command += [image, "bash", str(script_file.relative_to(REPO_ROOT))]

    try:
        cmd(command)
    except RuntimeError as e:
        clean_exit(f"Error invoking Docker: {e}")
    except KeyboardInterrupt:
        cmd(["docker", "stop", "--time", "1", name])
    finally:
        if os.getenv("DEBUG", "0") != "1":
            script_file.unlink()


def docs(
    tutorial_pattern: str | None = None,
    cpu: bool = False,
    full: bool = False,
    interactive: bool = False,
    skip_build: bool = False,
    docker_image: str | None = None,
) -> None:
    """
    Build the documentation from gallery/ and docs/. By default this builds only
    the Python docs without any tutorials.

    arguments:
    full -- Build all language docs, not just Python (cannot be used with --cpu)
    cpu -- Use the 'ci_cpu' Docker image (useful for building docs on a machine without a GPU)
    tutorial-pattern -- Regex for which tutorials to execute when building docs (cannot be used with --cpu)
    skip_build -- skip build and setup scripts
    interactive -- start a shell after running build / test scripts
    docker-image -- manually specify the docker image to use
    """
    build_dir = get_build_dir("gpu")

    extra_setup = []
    image = "ci_gpu" if docker_image is None else docker_image
    if cpu:
        # TODO: Change this to tlcpack/docs once that is uploaded
        image = "ci_cpu" if docker_image is None else docker_image
        build_dir = get_build_dir("cpu")
        config_script = " && ".join(
            [
                f"mkdir -p {build_dir}",
                f"pushd {build_dir}",
                "cp ../cmake/config.cmake .",
                "popd",
            ]
        )

        # These are taken from the ci-gpu image via pip freeze, consult that
        # if there are any changes: https://github.com/apache/tvm/tree/main/docs#native
        requirements = [
            "Sphinx==8.1.3",
            "sphinx_autodoc_annotation~=1.0",
            "sphinx-gallery==0.20.0",
            "sphinx-book-theme==1.1.4",
            "pydata-sphinx-theme==0.15.4",
            "autodocsumm==0.2.14",
            "image==1.5.33",
            "matplotlib==3.10.8",
            "commonmark==0.9.1",
            "docutils==0.21.2",
            "Pillow==12.1.1",
        ]

        extra_setup = [
            "uv pip install " + " ".join(requirements),
        ]
    else:
        check_gpu()
        config_script = f"./tests/scripts/task_config_build_gpu.sh {build_dir}"

    scripts = extra_setup + [
        config_script,
        f"./tests/scripts/task_build.py --build-dir {build_dir}",
    ]

    if skip_build:
        scripts = []

    scripts.append("./tests/scripts/task_python_docs.sh")

    if tutorial_pattern is None:
        tutorial_pattern = os.getenv("TVM_TUTORIAL_EXEC_PATTERN", ".py" if full else "none")

    env = {
        "TVM_TUTORIAL_EXEC_PATTERN": tutorial_pattern,
        "PYTHON_DOCS_ONLY": "0" if full else "1",
        "IS_LOCAL": "1",
        "TVM_LIBRARY_PATH": str(REPO_ROOT / build_dir),
    }
    docker(name=gen_name("docs"), image=image, scripts=scripts, env=env, interactive=interactive)
    print_color(
        col.GREEN,
        "Done building the docs. You can view them by running "
        "'python3 tests/scripts/ci.py serve-docs' and visiting:"
        " http://localhost:8000 in your browser.",
        bold=True,
    )


def serve_docs(directory: str = "_docs") -> None:
    """
    Serve the docs using Python's http server

    arguments:
    directory -- Directory to serve from
    """
    directory_path = Path(directory)
    if not directory_path.exists():
        clean_exit("Docs have not been built, run 'ci.py docs' first")
    cmd([sys.executable, "-m", "http.server"], cwd=directory_path)


def lint(interactive: bool = False, docker_image: str | None = None) -> None:
    """
    Run lint checks locally.

    arguments:
    interactive -- start a shell after running build / test scripts when using --docker-image
    docker-image -- manually specify a docker image to use
    """
    scripts = ["pre-commit run --all-files"]
    if docker_image is None:
        if interactive:
            clean_exit("--interactive requires --docker-image")
        cmd(scripts[0].split())
        return

    docker(
        name=gen_name("lint"),
        image=docker_image,
        scripts=scripts,
        env={},
        interactive=interactive,
    )


Option = tuple[str, list[str]]


def generate_command(
    name: str,
    options: dict[str, Option],
    help: str,
    precheck: Callable[[], None] | None = None,
    post_build: list[str] | None = None,
    additional_flags: dict[str, str] | None = None,
    env: dict[str, str] | None = None,
):
    """
    Helper to generate CLIs that:
    1. Build a with a config matching a specific CI Docker image (e.g. 'cpu')
    2. Run tests (either a pre-defined set from scripts or manually via invoking
       pytest)
    3. (optional) Drop down into a terminal into the Docker container
    """

    def fn(
        tests: list[str] | None = None,
        skip_build: bool = False,
        interactive: bool = False,
        docker_image: str | None = None,
        verbose: bool = False,
        **kwargs,
    ) -> None:
        """
        arguments:
        tests -- pytest test IDs (e.g. tests/python or tests/python/a_file.py::a_test[param=1])
        skip_build -- skip build and setup scripts
        interactive -- start a shell after running build / test scripts
        docker-image -- manually specify the docker image to use
        verbose -- run verbose build
        """
        if precheck is not None:
            precheck()

        build_dir = get_build_dir(name)

        if skip_build:
            scripts = []
        else:
            scripts = [
                f"./tests/scripts/task_config_build_{name}.sh {build_dir}",
                f"./tests/scripts/task_build.py --build-dir {build_dir}",
            ]

        if post_build is not None:
            scripts += post_build

        # Check that a test suite was not used alongside specific test names
        if any(v for v in kwargs.values()) and tests is not None:
            option_flags = ", ".join([f"--{k}" for k in options.keys()])
            clean_exit(f"{option_flags} cannot be used with --tests")

        if tests is not None:
            scripts.append(f"python3 -m pytest {' '.join(tests)}")

        # Add named test suites
        for option_name, (_, extra_scripts) in options.items():
            if kwargs.get(option_name, False):
                scripts.extend(script.format(build_dir=build_dir) for script in extra_scripts)

        docker_env = {
            # Need to specify the library path manually or else TVM can't
            # determine which build directory to use (i.e. if there are
            # multiple copies of libtvm_compiler.so / libtvm_runtime.so laying around)
            "TVM_LIBRARY_PATH": str(REPO_ROOT / get_build_dir(name)),
            "VERBOSE": "true" if verbose else "false",
        }

        if env is not None:
            docker_env.update(env)

        docker(
            name=gen_name(f"ci-{name}"),
            image=f"ci_{name}" if docker_image is None else docker_image,
            scripts=scripts,
            env=docker_env,
            interactive=interactive,
            additional_flags=additional_flags,
        )

    fn.__name__ = name

    return fn, options, help


def check_arm_qemu() -> None:
    """
    Check if a machine is ready to run an ARM Docker image
    """
    machine = platform.machine().lower()
    if "arm" in machine or "aarch64" in machine:
        # No need to check anything if the machine runs ARM
        return

    binfmt = Path("/proc/sys/fs/binfmt_misc")
    if not binfmt.exists() or len(list(binfmt.glob("qemu-*"))) == 0:
        clean_exit(
            textwrap.dedent(
                """
        You must run a one-time setup to use ARM containers on x86 via QEMU:

            sudo apt install -y qemu binfmt-support qemu-user-static
            docker run --rm --privileged multiarch/qemu-user-static --reset -p yes

        See https://www.stereolabs.com/docs/docker/building-arm-container-on-x86/ for details""".strip(
                    "\n"
                )
            )
        )


def cli_name(s: str) -> str:
    return s.replace("_", "-")


def typing_get_origin(annotation):
    return typing.get_origin(annotation)


def typing_get_args(annotation):
    return typing.get_args(annotation)


def is_optional_type(annotation):
    return (
        hasattr(annotation, "__origin__")
        and (typing_get_origin(annotation) == typing.Union)
        and (type(None) in typing_get_args(annotation))
    )


def add_subparser(
    func: Callable,
    subparsers: Any,
    options: dict[str, Option] | None = None,
    help: str | None = None,
) -> Any:
    """
    Utility function to make it so subparser commands can be defined locally
    as a function rather than directly via argparse and manually dispatched
    out.
    """

    # Each function is intended follow the example for arguments in PEP257, so
    # split apart the function documentation from the arguments
    split = [s.strip() for s in func.__doc__.split("arguments:\n")]
    if len(split) == 1:
        args_help = None
        command_help = split[0]
    else:
        command_help, args_help = split

    if help is not None:
        command_help = help

    # Parse out the help text for each argument if present
    arg_help_texts = {}
    if args_help is not None:
        for line in args_help.split("\n"):
            line = line.strip()
            name, help_text = [t.strip() for t in line.split(" -- ")]
            arg_help_texts[cli_name(name)] = help_text

    subparser = subparsers.add_parser(cli_name(func.__name__), help=command_help)

    seen_prefixes = set()

    # Add each parameter to the subparser
    signature = inspect.signature(func)
    for name, value in signature.parameters.items():
        if name == "kwargs":
            continue

        arg_cli_name = cli_name(name)
        kwargs: dict[str, str | bool] = {"help": arg_help_texts[arg_cli_name]}

        is_optional = is_optional_type(value.annotation)
        if is_optional:
            arg_type = typing_get_args(value.annotation)[0]
        else:
            arg_type = value.annotation

        # Grab the default value if present
        has_default = False
        if value.default is not value.empty:
            kwargs["default"] = value.default
            has_default = True

        # Check if it should be a flag
        if arg_type is bool:
            kwargs["action"] = "store_true"
        else:
            kwargs["required"] = not is_optional and not has_default

        if str(arg_type).startswith("typing.List"):
            kwargs["action"] = "append"

        if arg_cli_name[0] not in seen_prefixes:
            subparser.add_argument(f"-{arg_cli_name[0]}", f"--{arg_cli_name}", **kwargs)
            seen_prefixes.add(arg_cli_name[0])
        else:
            subparser.add_argument(f"--{arg_cli_name}", **kwargs)

    if options is not None:
        for option_name, (help, _) in options.items():
            option_cli_name = cli_name(option_name)
            if option_cli_name[0] not in seen_prefixes:
                subparser.add_argument(
                    f"-{option_cli_name[0]}", f"--{option_cli_name}", action="store_true", help=help
                )
                seen_prefixes.add(option_cli_name[0])
            else:
                subparser.add_argument(f"--{option_cli_name}", action="store_true", help=help)

    return subparser


CPP_UNITTEST = ("run c++ unitests", ["./tests/scripts/task_cpp_unittest.sh {build_dir}"])

generated = [
    generate_command(
        name="gpu",
        help="Run GPU build and test(s)",
        options={
            "cpp": CPP_UNITTEST,
            "unittest": (
                "run unit tests",
                [
                    "./tests/scripts/task_java_unittest.sh",
                    "./tests/scripts/task_python_unittest_gpuonly.sh",
                ],
            ),
        },
    ),
    generate_command(
        name="cpu",
        help="Run CPU build and test(s)",
        options={
            "cpp": CPP_UNITTEST,
            "unittest": (
                "run unit tests",
                [
                    "./tests/scripts/task_python_unittest.sh",
                ],
            ),
        },
    ),
    generate_command(
        name="wasm",
        help="Run WASM build and test(s)",
        options={
            "cpp": CPP_UNITTEST,
            "test": ("run WASM tests", ["./tests/scripts/task_web_wasm.sh"]),
        },
    ),
    generate_command(
        name="arm",
        help="Run ARM build and test(s) (native or via QEMU on x86)",
        precheck=check_arm_qemu,
        options={
            "cpp": CPP_UNITTEST,
            "python": (
                "run full Python tests",
                [
                    "./tests/scripts/task_python_unittest.sh",
                ],
            ),
        },
    ),
]


def main():
    description = """
    Run CI jobs locally via Docker. This facilitates reproducing CI failures for
    fast iteration. Note that many of the Docker images required are large (the
    CPU and GPU images are both over 25GB) and may take some time to download on first use.
    """
    parser = argparse.ArgumentParser(description=description)
    subparsers = parser.add_subparsers(dest="command")

    commands = {}

    # Add manually defined commands
    for func in [docs, serve_docs, lint]:
        add_subparser(func, subparsers)
        commands[cli_name(func.__name__)] = func

    # Add generated commands
    for func, options, help in generated:
        add_subparser(func, subparsers, options, help)
        commands[cli_name(func.__name__)] = func

    args = parser.parse_args()

    if args.command is None:
        # Command not found in list, error out
        parser.print_help()
        exit(1)

    func = commands[args.command]

    # Extract out the parsed args and invoke the relevant function
    kwargs = {k: getattr(args, k) for k in dir(args) if not k.startswith("_") and k != "command"}
    func(**kwargs)


if __name__ == "__main__":
    main()
