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

import argparse
import json
from multiprocessing.pool import ThreadPool
import shlex
import shutil
from fnmatch import fnmatch
from pathlib import Path
from typing import Any, List, Tuple
from impl.common import (
    CROSVM_ROOT,
    all_tracked_files,
    chdir,
    cmd,
    cwd_context,
    parallel,
    print_timing_info,
    quoted,
    record_time,
)

# List of globs matching files in the source tree required by tests at runtime.
# This is hard-coded specifically for crosvm tests.
TEST_DATA_FILES = [
    # Requried by nextest to obtain metadata
    "*.toml",
    # Configured by .cargo/config.toml to execute tests with the right emulator
    ".cargo/runner.py",
    # Requried by plugin tests
    "crosvm_plugin/crosvm.h",
    "tests/plugin.policy",
]

TEST_DATA_EXCLUDE = [
    # config.toml is configured for x86 hosts. We cannot use that for remote tests.
    ".cargo/config.toml",
]

cargo = cmd("cargo")
tar = cmd("tar")
rust_strip = cmd("rust-strip")


def collect_rust_libs():
    "Collect rust shared libraries required by the tests at runtime."
    lib_dir = Path(cmd("rustc --print=sysroot").stdout()) / "lib"
    for lib_file in lib_dir.glob("libstd-*"):
        yield (lib_file, Path("debug/deps") / lib_file.name)
    for lib_file in lib_dir.glob("libtest-*"):
        yield (lib_file, Path("debug/deps") / lib_file.name)


def collect_test_binaries(metadata: Any, strip: bool):
    "Collect all test binaries that are needed to run the tests."
    target_dir = Path(metadata["rust-build-meta"]["target-directory"])
    test_binaries = [
        Path(suite["binary-path"]).relative_to(target_dir)
        for suite in metadata["rust-binaries"].values()
    ]

    non_test_binaries = [
        Path(binary["path"])
        for crate in metadata["rust-build-meta"]["non-test-binaries"].values()
        for binary in crate
    ]

    def process_binary(binary_path: Path):
        source_path = target_dir / binary_path
        destination_path = binary_path
        if strip:
            stripped_path = source_path.with_suffix(".stripped")
            if (
                not stripped_path.exists()
                or source_path.stat().st_ctime > stripped_path.stat().st_ctime
            ):
                rust_strip(f"--strip-all {source_path} -o {stripped_path}").fg()
            return (stripped_path, destination_path)
        else:
            return (source_path, destination_path)

    # Parallelize rust_strip calls.
    pool = ThreadPool()
    return pool.map(process_binary, test_binaries + non_test_binaries)


def collect_test_data_files():
    "List additional files from the source tree that are required by tests at runtime."
    for file in all_tracked_files():
        for glob in TEST_DATA_FILES:
            if fnmatch(str(file), glob):
                if str(file) not in TEST_DATA_EXCLUDE:
                    yield (file, file)
                break


def collect_files(metadata: Any, output_directory: Path, strip_binaries: bool):
    # List all files we need as (source path, path in output_directory) tuples
    manifest: List[Tuple[Path, Path]] = [
        *collect_test_binaries(metadata, strip=strip_binaries),
        *collect_rust_libs(),
        *collect_test_data_files(),
    ]

    # Create all target directories
    for folder in set((output_directory / d).parent.resolve() for _, d in manifest):
        folder.mkdir(exist_ok=True, parents=True)

    # Use multiple processes to copy the files (and only if they are newer than existing ones)
    parallel(
        *(cmd("cp -u", source, output_directory / destination) for source, destination in manifest)
    ).fg()


def generate_run_script(metadata: Any, output_directory: Path):
    # Generate metadata files for nextest
    binares_metadata_file = "binaries-metadata.json"
    (output_directory / binares_metadata_file).write_text(json.dumps(metadata))
    cargo_metadata_file = "cargo-metadata.json"
    cargo("metadata --format-version 1").write_to(output_directory / cargo_metadata_file)

    # Put together command line to run nextest
    run_cmd = [
        "cargo-nextest",
        "nextest",
        "run",
        f"--binaries-metadata={binares_metadata_file}",
        f"--cargo-metadata={cargo_metadata_file}",
        "--target-dir-remap=.",
        "--workspace-remap=.",
    ]
    command_line = [
        "#!/usr/bin/env bash",
        'cd "$(dirname "${BASH_SOURCE[0]}")" || die',
        f'{shlex.join(run_cmd)} "$@"',
    ]

    # Write command to a unix shell script
    shell_script = output_directory / "run.sh"
    shell_script.write_text("\n".join(command_line))
    shell_script.chmod(0o755)

    # TODO(denniskempin): Add an equivalent windows bash script


def generate_archive(output_directory: Path, output_archive: Path):
    with cwd_context(output_directory.parent):
        tar("-ca", output_directory.name, "-f", output_archive).fg()


def main():
    """
    Builds a package to execute tests remotely.

    ## Basic usage

    ```
    $ tools/nextest_package -o foo.tar.zst ... nextest args
    ```

    The archive will contain all necessary test binaries, required shared libraries and test data
    files required at runtime.
    A cargo nextest binary is included along with a `run.sh` script to invoke it with the required
    arguments. THe archive can be copied anywhere and executed:

    ```
    $ tar xaf foo.tar.zst && cd foo.tar.d && ./run.sh
    ```

    ## Nextest Arguments

    All additional arguments will be passed to `nextest list`. Additional arguments to `nextest run`
    can be passed to the `run.sh` invocation.

    For example:

    ```
    $ tools/nextest_package -d foo --tests
    $ cd foo && ./run.sh --test-threads=1
    ```

    Will only list and package integration tests (--tests) and run them with --test-threads=1.

    ## Stripping Symbols

    Debug symbols are stripped by default to reduce the package size. This can be disabled via
    the `--no-strip` argument.

    """
    parser = argparse.ArgumentParser()
    parser.add_argument("--no-strip", action="store_true")
    parser.add_argument("--output-directory", "-d")
    parser.add_argument("--output-archive", "-o")
    parser.add_argument("--clean", action="store_true")
    parser.add_argument("--timing-info", action="store_true")
    (args, nextest_list_args) = parser.parse_known_args()
    chdir(CROSVM_ROOT)

    # Determine output archive / directory
    output_directory = Path(args.output_directory).resolve() if args.output_directory else None
    output_archive = Path(args.output_archive).resolve() if args.output_archive else None
    if not output_directory and output_archive:
        output_directory = output_archive.with_suffix(".d")
    if not output_directory:
        print("Must specify either --output-directory or --output-archive")
        return

    if args.clean and output_directory.exists():
        shutil.rmtree(output_directory)

    with record_time("Listing tests"):
        cargo(
            "nextest list",
            *(quoted(a) for a in nextest_list_args),
        ).fg()
    with record_time("Listing tests metadata"):
        metadata = cargo(
            "nextest list --list-type binaries-only --message-format json",
            *(quoted(a) for a in nextest_list_args),
        ).json()

    with record_time("Collecting files"):
        collect_files(metadata, output_directory, strip_binaries=not args.no_strip)
        generate_run_script(metadata, output_directory)

    if output_archive:
        with record_time("Generating archive"):
            generate_archive(output_directory, output_archive)

    if args.timing_info:
        print_timing_info()


if __name__ == "__main__":
    main()
