#!/usr/bin/env python3
# Copyright 2024 The IREE Authors
#
# Licensed under the Apache License v2.0 with LLVM Exceptions.
# See https://llvm.org/LICENSE.txt for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception

"""Dev package bisect script.

This connects the `git bisect` tool (https://git-scm.com/docs/git-bisect)
with IREE's package builds, allowing developers to run tests through commit
history efficiently. For example, this can be used to spot at which commit
an `iree-compile` command started failing.

Requirements:
    git     (https://git-scm.com/)
    gh      (https://cli.github.com/)
    Linux   (at least until IREE builds packages for other systems at each commit)
    Python 3.11

Example usage:
    bisect_packages.py \
        --good-ref=iree-3.0.0 \
        --bad-ref=iree-3.1.0rc20241122 \
        --test-script=bisect_example_timestamp.sh

    bisect_packages.py \
        --good-ref=iree-3.0.0 \
        --bad-ref=main \
        --test-command="iree-compile --iree-hal-target-device=local --iree-hal-local-target-device-backends=llvm-cpu -o /dev/null /tmp/repro.mlir"
"""


from pathlib import Path

import argparse
import os
import platform
import shutil
import subprocess
import sys

THIS_DIR = Path(__file__).parent.resolve()
REPO_ROOT = THIS_DIR.parent.parent.parent


def parse_arguments():
    parser = argparse.ArgumentParser(description="Git release bisect tool")
    # TODO(scotttodd): add --interactive mode that prompts like git bisect does
    parser.add_argument(
        "--good-ref",
        help="The git ref (commit hash, branch name, tag name, etc.) at the lower end of the range",
        required=True,
    )
    parser.add_argument(
        "--bad-ref",
        help="The git ref (commit hash, branch name, tag name, etc.) at the upper end of the range",
        required=True,
    )
    parser.add_argument(
        "--work-dir",
        help="The working directory to use. Defaults to ~/.iree/bisect/",
        default=Path.home() / ".iree" / "bisect",
        type=Path,
    )
    test_group = parser.add_mutually_exclusive_group(required=True)
    test_group.add_argument(
        "--test-script",
        help="Path to the bash script file to run at each commit (or use --test-command)",
    )
    test_group.add_argument(
        "--test-command",
        help="The bash command to run at each commit (or use --test-script)",
    )
    # TODO(scotttodd): choice between manual or script (`git bisect run`) to use
    #                  note that a "manual" mode would need developers to
    #                  run the venv setup code themselves

    parser.add_argument(
        "--ignore-system-requirements",
        help="Ignores system requirements like Python 3.11 and tries to run even if they are not met.",
        action="store_true",
        default=False,
    )
    # TODO(scotttodd): --clean arg to `rm -rf` the workdir
    # TODO(scotttodd): control over logging
    #   redirect stdout/stderr from test script separate files in the workdir?

    return parser.parse_args()


def check_system_requirements(ignore_system_requirements):
    print("")
    system_check_okay = True

    # Check for Linux.
    print(
        f"  Current platform is '{platform.platform()}', platform.system is '{platform.system()}'."
    )
    if "Linux" not in platform.system():
        print("  ERROR! platform.system must be 'Linux'.", file=sys.stderr)
        system_check_okay = False

    # Check for Python 3.11.
    print("")
    print(f"  Current Python version is '{sys.version}'. This script requires 3.11.")
    if sys.version_info[:2] == (3, 11):
        python311_path = "python"
    else:
        python311_path = shutil.which("python3.11")
        if python311_path:
            print(f"  Found python3.11 at '{python311_path}', using that instead.")
        else:
            print(
                "  ERROR! Could not find Python version 3.11. Python version must be 3.11 to match package builds.",
                file=sys.stderr,
            )
            print(
                "  See `.github/workflows/pkgci_build_packages.yml` and `build_tools/pkgci/build_linux_packages.sh`.",
                file=sys.stderr,
            )
            system_check_okay = False

    # Check for 'gh'.
    print("")
    gh_path = shutil.which("gh")
    if not gh_path:
        print(
            "  ERROR! Could not find 'gh'. Install by following https://github.com/cli/cli#installation.",
            file=sys.stderr,
        )
        system_check_okay = False
    else:
        print(f"  Found gh at '{gh_path}'.")

    if not system_check_okay:
        print("")
        if ignore_system_requirements:
            print(
                "One or more configuration issues detected, but --ignore-system-requirements is set. Continuing.",
                file=sys.stderr,
            )
            return
        print(
            "One or more configuration issues detected. Fix the reported issues or pass --ignore-system-requirements to try running anyways. Exiting.",
            file=sys.stderr,
        )
        print("")
        print("------------------------------------------------------------------")
        sys.exit(1)

    return python311_path


def main(args):
    print("Welcome to bisect_packages.py!")

    print("")
    print("------------------------------------------------------------------")
    print("--------- Configuration ------------------------------------------")
    print("------------------------------------------------------------------")
    print("")
    print(f"  Searching range         : '{args.good_ref}' - '{args.bad_ref}'")

    print(f"  Using working directory : '{args.work_dir}'")
    Path.mkdir(args.work_dir, parents=True, exist_ok=True)

    if args.test_script:
        print(f"  Using test script       : '{args.test_script}'")
    elif args.test_command:
        print(f"  Using test command      : '{args.test_command}'")

    python311_path = check_system_requirements(args.ignore_system_requirements)

    print("")
    print("------------------------------------------------------------------")

    # Create new script in working directory that:
    #   * downloads the packages from the release and installs them
    #   * runs the original test script
    bisect_run_script = args.work_dir / "bisect_run_script.sh"
    with open(bisect_run_script, "w") as bisect_run_script_file:
        contents = ""
        contents += "#!/bin/bash\n"

        contents += "\n"
        contents += "#########################################\n"
        contents += "###### BISECT RELEASE SCRIPT SETUP ######\n"
        contents += "#########################################\n"
        contents += "\n"
        contents += "set -xeuo pipefail\n"
        contents += "\n"

        # Download packages for REF_HASH and install them into REF_HASH/.venv/.
        contents += "REF_HASH=$(git rev-parse BISECT_HEAD)\n"
        contents += f'"{python311_path}" '
        contents += str((THIS_DIR / ".." / "setup_venv.py").as_posix())
        contents += f" {args.work_dir}/"
        contents += "${REF_HASH}/.venv"
        contents += f" --artifact-path={args.work_dir}/"
        contents += "${REF_HASH} "
        contents += " --fetch-git-ref=${REF_HASH}\n"
        # Prepend the venv bin dir to $PATH. This is similar to running
        #   `source .venv/bin/activate`
        # while scoped to this process. Note that this does not modify
        # $PYTHONHOME or support the `deactivate` command.
        contents += f'PATH="{args.work_dir}/$REF_HASH/.venv/bin:$PATH"\n'

        contents += "\n"
        # Controlled failure - don't immediately exit. See below.
        contents += "set +e\n"
        contents += "\n"
        contents += "#########################################\n"
        contents += "############ ORIGINAL SCRIPT ############\n"
        contents += "#########################################\n"
        contents += "\n"

        if args.test_script:
            with open(args.test_script, "r") as original_script:
                contents += original_script.read()
        elif args.test_command:
            contents += args.test_command + "\n"

        contents += "\n"
        contents += "#########################################\n"
        contents += "##### BISECT RELEASE SCRIPT CLEANUP #####\n"
        contents += "#########################################\n"
        contents += "\n"
        # Controlled failure, See `set +e` above.
        # `git bisect` is looking for exit values in the 1-127 range, while
        # iree-compile can exit with value 245 sometimes:
        # https://git-scm.com/docs/git-bisect#_bisect_run. Here we just check
        # for non-zero and normalize back to 1.
        contents += "RET_VALUE=$?\n"
        contents += "if [ $RET_VALUE -ne 0 ]; then\n"
        contents += "    exit 1\n"
        contents += "fi\n"

        bisect_run_script_file.write(contents)

    os.chmod(str(bisect_run_script), 0o744)  # Set as executable.

    print("")
    print("------------------------------------------------------------------")
    print("--------- Running git bisect -------------------------------------")
    print("------------------------------------------------------------------")
    print("")
    subprocess.check_call(["git", "bisect", "reset"], cwd=REPO_ROOT)
    subprocess.check_call(
        [
            "git",
            "bisect",
            "start",
            # Just update the BISECT_HEAD reference instead of checking out the
            # ref for each iteration of the bisect process. We won't be building
            # from source and this script lives in the source tree, so keep the
            # repository in a stable state.
            # Note: scripts can access the hash via `git rev-parse BISECT_HEAD`.
            "--no-checkout",
            # We only care about the merge/aggregate commit when branches were
            # merged. Ignore ancestors of merge commits.
            "--first-parent",
        ],
        cwd=REPO_ROOT,
    )
    subprocess.check_call(["git", "bisect", "good", args.good_ref], cwd=REPO_ROOT)
    subprocess.check_call(["git", "bisect", "bad", args.bad_ref], cwd=REPO_ROOT)
    subprocess.check_call(
        ["git", "bisect", "run", str(bisect_run_script)], cwd=REPO_ROOT
    )

    print("")


if __name__ == "__main__":
    main(parse_arguments())
