#!/usr/bin/env python3
# Copyright 2022 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import os
import typing
from typing import Generator, List, Literal, Optional, Tuple, Union

from impl.common import (
    CROSVM_ROOT,
    TOOLS_ROOT,
    Triple,
    argh,
    chdir,
    cmd,
    run_main,
)
from impl.presubmit import Check, CheckContext, run_checks, Group

python = cmd("python3")
mypy = cmd("mypy").with_color_env("MYPY_FORCE_COLOR")
black = cmd("black").with_color_arg(always="--color", never="--no-color")
mdformat = cmd("mdformat")
lucicfg = cmd("third_party/depot_tools/lucicfg")

# All supported platforms as a type and a list.
Platform = Literal["x86_64", "aarch64", "mingw64", "armhf", "riscv64"]
PLATFORMS: Tuple[Platform, ...] = typing.get_args(Platform)

ClippyOnlyPlatform = Literal["android"]
CLIPPY_ONLY_PLATFORMS: Tuple[ClippyOnlyPlatform, ...] = typing.get_args(ClippyOnlyPlatform)


def platform_is_supported(platform: Union[Platform, ClippyOnlyPlatform]):
    "Returns true if the platform is available as a target in rustup."
    triple = Triple.from_shorthand(platform)
    installed_toolchains = cmd("rustup target list --installed").lines()
    return str(triple) in installed_toolchains


####################################################################################################
# Check methods
#
# Each check returns a Command (or list of Commands) to be run to execute the check. They are
# registered and configured in the CHECKS list below.
#
# Some check functions are factory functions that return a check command for all supported
# platforms.


def check_python_tests(_: CheckContext):
    "Runs unit tests for python dev tooling."
    PYTHON_TESTS = [
        # Disabled due to b/309148074
        # "tests.cl_tests",
        "impl.common",
    ]
    return [python.with_cwd(TOOLS_ROOT).with_args("-m", file) for file in PYTHON_TESTS]


def check_python_types(context: CheckContext):
    "Run mypy type checks on python dev tooling."
    return [mypy("--pretty", file) for file in context.all_files]


def check_python_format(context: CheckContext):
    "Runs the black formatter on python dev tooling."
    return black.with_args(
        "--check" if not context.fix else None,
        *context.modified_files,
    )


def check_markdown_format(context: CheckContext):
    "Runs mdformat on all markdown files."
    if "blaze" in mdformat("--version").stdout():
        raise Exception(
            "You are using google's mdformat. "
            + "Please update your PATH to ensure the pip installed mdformat is available."
        )
    return mdformat.with_args(
        "--wrap 100",
        "--check" if not context.fix else "",
        *context.modified_files,
    )


def check_rust_format(context: CheckContext):
    "Runs rustfmt on all modified files."
    rustfmt = cmd(cmd("rustup +nightly which rustfmt").stdout())
    # Windows doesn't accept very long arguments: https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessa#:~:text=The%20maximum%20length%20of%20this%20string%20is%2032%2C767%20characters%2C%20including%20the%20Unicode%20terminating%20null%20character.%20If%20lpApplicationName%20is%20NULL%2C%20the%20module%20name%20portion%20of%20lpCommandLine%20is%20limited%20to%20MAX_PATH%20characters.
    return list(
        rustfmt.with_color_flag()
        .with_args("--check" if not context.fix else "")
        .foreach(context.modified_files, batch_size=10)
    )


def check_cargo_doc(_: CheckContext):
    "Runs cargo-doc and verifies that no warnings are emitted."
    return cmd("./tools/cargo-doc").with_env("RUSTDOCFLAGS", "-D warnings").with_color_flag()


def check_doc_tests(_: CheckContext):
    "Runs cargo doc tests. These cannot be run via nextest and run_tests."
    return cmd(
        "cargo test",
        "--doc",
        "--workspace",
        "--features=all-x86_64",
    ).with_color_flag()


def check_mdbook(_: CheckContext):
    "Runs cargo-doc and verifies that no warnings are emitted."
    return cmd("mdbook build docs/book/")


def check_crosvm_tests(platform: Platform):
    def check(_: CheckContext):
        if not platform_is_supported(platform):
            return None
        dut = None
        if os.access("/dev/kvm", os.W_OK):
            if platform == "x86_64":
                dut = "--dut=vm"
            elif platform == "aarch64":
                dut = "--dut=vm"
        return cmd("./tools/run_tests --verbose --platform", platform, dut).with_color_flag()

    check.__doc__ = f"Runs all crosvm tests for {platform}."

    return check


def check_crosvm_unit_tests(platform: Platform):
    def check(_: CheckContext):
        if not platform_is_supported(platform):
            return None
        return cmd("./tools/run_tests --verbose --platform", platform).with_color_flag()

    check.__doc__ = f"Runs crosvm unit tests for {platform}."

    return check


def check_crosvm_build(
    platform: Platform, features: Optional[str] = None, no_default_features: bool = False
):
    def check(_: CheckContext):
        return cmd(
            "./tools/run_tests --no-run --verbose --platform",
            platform,
            f"--features={features}" if features is not None else None,
            "--no-default-features" if no_default_features else None,
        ).with_color_flag()

    check.__doc__ = f"Builds crosvm for {platform} with features {features}."

    return check


def check_clippy(platform: Union[Platform, ClippyOnlyPlatform]):
    def check(context: CheckContext):
        if not platform_is_supported(platform):
            return None
        return cmd(
            "./tools/clippy --platform",
            platform,
            "--fix" if context.fix else None,
        ).with_color_flag()

    check.__doc__ = f"Runs clippy for {platform}."

    return check


def custom_check(name: str, can_fix: bool = False):
    "Custom checks are written in python in tools/custom_checks. This is a wrapper to call them."

    def check(context: CheckContext):
        return cmd(
            TOOLS_ROOT / "custom_checks",
            name,
            *context.modified_files,
            "--fix" if can_fix and context.fix else None,
        )

    check.__name__ = name.replace("-", "_")
    check.__doc__ = f"Runs tools/custom_check {name}"
    return check


####################################################################################################
# Checks configuration
#
# Configures which checks are available and on which files they are run.
# Check names default to the function name minus the check_ prefix

CHECKS: List[Check] = [
    Check(
        check_rust_format,
        files=["**.rs"],
        exclude=["system_api/src/bindings/*"],
        can_fix=True,
    ),
    Check(
        check_mdbook,
        files=["docs/**/*"],
    ),
    Check(
        check_cargo_doc,
        files=["**.rs", "**Cargo.toml"],
        priority=True,
    ),
    Check(
        check_doc_tests,
        files=["**.rs", "**Cargo.toml"],
        priority=True,
    ),
    Check(
        check_python_tests,
        files=["tools/**.py"],
        python_tools=True,
        priority=True,
    ),
    Check(
        check_python_types,
        files=["tools/**.py"],
        exclude=[
            "tools/windows/*",
            "tools/contrib/memstats_chart/*",
            "tools/contrib/cros_tracing_analyser/*",
        ],
        python_tools=True,
    ),
    Check(
        check_python_format,
        files=["**.py"],
        python_tools=True,
        exclude=["infra/recipes.py"],
        can_fix=True,
    ),
    Check(
        check_markdown_format,
        files=["**.md"],
        exclude=[
            "infra/README.recipes.md",
            "docs/book/src/appendix/memory_layout.md",
        ],
        can_fix=True,
    ),
    *(
        Check(
            check_crosvm_build(platform, features="default"),
            custom_name=f"crosvm_build_default_{platform}",
            files=["**.rs"],
            priority=True,
        )
        for platform in PLATFORMS
    ),
    *(
        Check(
            check_crosvm_build(platform, features="", no_default_features=True),
            custom_name=f"crosvm_build_no_default_{platform}",
            files=["**.rs"],
            priority=True,
        )
        # TODO: b/260607247 crosvm does not compile with no-default-features on mingw64
        for platform in PLATFORMS
        if platform != "mingw64"
    ),
    *(
        Check(
            check_crosvm_tests(platform),
            custom_name=f"crosvm_tests_{platform}",
            files=["**.rs"],
            priority=True,
        )
        for platform in PLATFORMS
    ),
    *(
        Check(
            check_crosvm_unit_tests(platform),
            custom_name=f"crosvm_unit_tests_{platform}",
            files=["**.rs"],
            priority=True,
        )
        for platform in PLATFORMS
    ),
    *(
        Check(
            check_clippy(platform),
            custom_name=f"clippy_{platform}",
            files=["**.rs"],
            can_fix=True,
            priority=True,
        )
        for platform in (*PLATFORMS, *CLIPPY_ONLY_PLATFORMS)
    ),
    Check(
        custom_check("check-copyright-header"),
        files=["**.rs", "**.py", "**.c", "**.h", "**.policy", "**.sh"],
        exclude=[
            "infra/recipes.py",
            "hypervisor/src/whpx/whpx_sys/*.h",
            "third_party/vmm_vhost/*",
            "net_sys/src/lib.rs",
            "system_api/src/bindings/*",
        ],
        python_tools=True,
        can_fix=True,
    ),
    Check(
        custom_check("check-rust-features"),
        files=["**Cargo.toml"],
    ),
    Check(
        custom_check("check-rust-lockfiles"),
        files=["**Cargo.toml"],
    ),
    Check(
        custom_check("check-line-endings"),
    ),
    Check(
        custom_check("check-file-ends-with-newline"),
        exclude=[
            "**.h264",
            "**.vp8",
            "**.vp9",
            "**.ivf",
            "**.bin",
            "**.png",
            "**.min.js",
            "**.drawio",
            "**.json",
            "**.dtb",
            "**.dtbo",
        ],
    ),
]

####################################################################################################
# Group configuration
#
# Configures pre-defined groups of checks. Some are configured for CI builders and others
# are configured for convenience during local development.

GROUPS: List[Group] = [
    # The default group is run if no check or group is explicitly set
    Group(
        name="default",
        doc="Checks run by default",
        checks=[
            "default_health_checks",
            # Run only one task per platform to prevent blocking on the build cache.
            "crosvm_tests_x86_64",
            "crosvm_unit_tests_aarch64",
            "crosvm_unit_tests_mingw64",
            "clippy_armhf",
        ],
    ),
    Group(
        name="quick",
        doc="Runs a quick subset of presubmit checks.",
        checks=[
            "default_health_checks",
            "crosvm_unit_tests_x86_64",
            "clippy_aarch64",
        ],
    ),
    Group(
        name="all",
        doc="Run checks of all builders.",
        checks=[
            "health_checks",
            *(f"linux_{platform}" for platform in PLATFORMS),
            *(f"clippy_{platform}" for platform in CLIPPY_ONLY_PLATFORMS),
        ],
    ),
    # Convenience groups for local usage:
    Group(
        name="clippy",
        doc="Runs clippy for all platforms",
        checks=[f"clippy_{platform}" for platform in PLATFORMS + CLIPPY_ONLY_PLATFORMS],
    ),
    Group(
        name="unit_tests",
        doc="Runs unit tests for all platforms",
        checks=[f"crosvm_unit_tests_{platform}" for platform in PLATFORMS],
    ),
    Group(
        name="format",
        doc="Runs all formatting checks (or fixes)",
        checks=[
            "rust_format",
            "markdown_format",
            "python_format",
        ],
    ),
    Group(
        name="default_health_checks",
        doc="Health checks to run by default",
        checks=[
            # Check if lockfiles need updating first. Otherwise another step may do the update.
            "rust_lockfiles",
            "copyright_header",
            "file_ends_with_newline",
            "line_endings",
            "markdown_format",
            "mdbook",
            "cargo_doc",
            "python_format",
            "python_types",
            "rust_features",
            "rust_format",
        ],
    ),
    # The groups below are used by builders in CI:
    Group(
        name="health_checks",
        doc="Checks run on the health_check builder",
        checks=[
            "default_health_checks",
            "doc_tests",
            "python_tests",
        ],
    ),
    Group(
        name="android-aarch64",
        doc="Checks run on the android-aarch64 builder",
        checks=[
            "clippy_android",
        ],
    ),
    *(
        Group(
            name=f"linux_{platform}",
            doc=f"Checks run on the linux-{platform} builder",
            checks=[
                f"crosvm_tests_{platform}",
                f"clippy_{platform}",
                f"crosvm_build_default_{platform}",
            ]
            # TODO: b/260607247 crosvm does not compile with no-default-features on mingw64
            + ([f"crosvm_build_no_default_{platform}"] if platform != "mingw64" else []),
        )
        for platform in PLATFORMS
    ),
]

# Turn both lists into dicts for convenience
CHECKS_DICT = dict((c.name, c) for c in CHECKS)
GROUPS_DICT = dict((c.name, c) for c in GROUPS)


def validate_config():
    "Validates the CHECKS and GROUPS configuration."
    for group in GROUPS:
        for check in group.checks:
            if check not in CHECKS_DICT and check not in GROUPS_DICT:
                raise Exception(f"Group {group.name} includes non-existing item {check}.")

    def find_in_group(check: Check):
        for group in GROUPS:
            if check.name in group.checks:
                return True
        return False

    for check in CHECKS:
        if not find_in_group(check):
            raise Exception(f"Check {check.name} is not included in any group.")

    all_names = [c.name for c in CHECKS] + [g.name for g in GROUPS]
    for name in all_names:
        if all_names.count(name) > 1:
            raise Exception(f"Check or group {name} is defined multiple times.")


def get_check_names_in_group(group: Group) -> Generator[str, None, None]:
    for name in group.checks:
        if name in GROUPS_DICT:
            yield from get_check_names_in_group(GROUPS_DICT[name])
        else:
            yield name


@argh.arg("--list-checks", default=False, help="List names of available checks and exit.")
@argh.arg("--fix", default=False, help="Asks checks to fix problems where possible.")
@argh.arg("--no-delta", default=False, help="Run on all files instead of just modified files.")
@argh.arg("--no-parallel", default=False, help="Do not run checks in parallel.")
@argh.arg(
    "checks_or_groups",
    help="List of checks or groups to run. Defaults to run the `default` group.",
)
def main(
    list_checks: bool = False,
    fix: bool = False,
    no_delta: bool = False,
    no_parallel: bool = False,
    *checks_or_groups: str,
):
    chdir(CROSVM_ROOT)
    validate_config()

    if not checks_or_groups:
        checks_or_groups = ("default",)

    # Resolve and validate the groups and checks provided
    check_names: List[str] = []
    for check_or_group in checks_or_groups:
        if check_or_group in CHECKS_DICT:
            check_names.append(check_or_group)
        elif check_or_group in GROUPS_DICT:
            check_names += list(get_check_names_in_group(GROUPS_DICT[check_or_group]))
        else:
            raise Exception(f"No such check or group: {check_or_group}")

    # Remove duplicates while preserving order
    check_names = list(dict.fromkeys(check_names))

    if list_checks:
        for check in check_names:
            print(check)
        return

    check_list = [CHECKS_DICT[name] for name in check_names]

    run_checks(
        check_list,
        fix=fix,
        run_on_all_files=no_delta,
        parallel=not no_parallel,
    )


def usage():
    groups = "\n".join(f"  {group.name}: {group.doc}" for group in GROUPS)
    checks = "\n".join(f"  {check.name}: {check.doc}" for check in CHECKS)
    return f"""\
Runs checks on the crosvm codebase.

Basic usage, to run a default selection of checks:

    ./tools/presubmit

Some checkers can fix issues they find (e.g. formatters, clippy, etc):

    ./tools/presubmit --fix


Various groups of presubmit checks can be run via:

    ./tools/presubmit group_name

Available groups are:
{groups}

You can also provide the names of specific checks to run:

    ./tools/presubmit check1 check2

Available checks are:
{checks}
"""


if __name__ == "__main__":
    run_main(main, usage=usage())
