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

"""Performs bisection on LLVM based off a .JSON file."""

import argparse
import enum
import errno
import json
import os
import subprocess
import sys

import chroot
import get_llvm_hash
import git_llvm_rev
import modify_a_tryjob
import update_chromeos_llvm_hash
import update_tryjob_status


class BisectionExitStatus(enum.Enum):
    """Exit code when performing bisection."""

    # Means that there are no more revisions available to bisect.
    BISECTION_COMPLETE = 126


def GetCommandLineArgs():
    """Parses the command line for the command line arguments."""

    # Default path to the chroot if a path is not specified.
    cros_root = os.path.expanduser("~")
    cros_root = os.path.join(cros_root, "chromiumos")

    # Create parser and add optional command-line arguments.
    parser = argparse.ArgumentParser(
        description="Bisects LLVM via tracking a JSON file."
    )

    # Add argument for other change lists that want to run alongside the tryjob
    # which has a change list of updating a package's git hash.
    parser.add_argument(
        "--parallel",
        type=int,
        default=3,
        help="How many tryjobs to create between the last good version and "
        "the first bad version (default: %(default)s)",
    )

    # Add argument for the good LLVM revision for bisection.
    parser.add_argument(
        "--start_rev",
        required=True,
        type=int,
        help="The good revision for the bisection.",
    )

    # Add argument for the bad LLVM revision for bisection.
    parser.add_argument(
        "--end_rev",
        required=True,
        type=int,
        help="The bad revision for the bisection.",
    )

    # Add argument for the absolute path to the file that contains information
    # on the previous tested svn version.
    parser.add_argument(
        "--last_tested",
        required=True,
        help="the absolute path to the file that contains the tryjobs",
    )

    # Add argument for the absolute path to the LLVM source tree.
    parser.add_argument(
        "--src_path",
        help="the path to the LLVM source tree to use (used for retrieving the "
        "git hash of each version between the last good version and first bad "
        "version)",
    )

    # Add argument for other change lists that want to run alongside the tryjob
    # which has a change list of updating a package's git hash.
    parser.add_argument(
        "--extra_change_lists",
        type=int,
        nargs="+",
        help="change lists that would like to be run alongside the change list "
        "of updating the packages",
    )

    # Add argument for custom options for the tryjob.
    parser.add_argument(
        "--options",
        required=False,
        nargs="+",
        help="options to use for the tryjob testing",
    )

    # Add argument for the builder to use for the tryjob.
    parser.add_argument(
        "--builder", required=True, help="builder to use for the tryjob testing"
    )

    # Add argument for the description of the tryjob.
    parser.add_argument(
        "--description",
        required=False,
        nargs="+",
        help="the description of the tryjob",
    )

    # Add argument for a specific chroot path.
    parser.add_argument(
        "--chromeos_path",
        default=cros_root,
        help="the path to the chroot (default: %(default)s)",
    )

    # Add argument for whether to display command contents to `stdout`.
    parser.add_argument(
        "--nocleanup",
        action="store_false",
        dest="cleanup",
        help="Abandon CLs created for bisectoin",
    )

    args_output = parser.parse_args()

    assert (
        args_output.start_rev < args_output.end_rev
    ), "Start revision %d is >= end revision %d" % (
        args_output.start_rev,
        args_output.end_rev,
    )

    if args_output.last_tested and not args_output.last_tested.endswith(
        ".json"
    ):
        raise ValueError(
            'Filed provided %s does not end in ".json"'
            % args_output.last_tested
        )

    return args_output


def GetRemainingRange(start, end, tryjobs):
    """Gets the start and end intervals in 'json_file'.

    Args:
        start: The start version of the bisection provided via the command line.
        end: The end version of the bisection provided via the command line.
        tryjobs: A list of tryjobs where each element is in the following
        format:
        [
            {[TRYJOB_INFORMATION]},
            {[TRYJOB_INFORMATION]},
            ...,
            {[TRYJOB_INFORMATION]}
        ]

    Returns:
        The new start version and end version for bisection, a set of revisions
        that are 'pending' and a set of revisions that are to be skipped.

    Raises:
        ValueError: The value for 'status' is missing or there is a mismatch
        between 'start' and 'end' compared to the 'start' and 'end' in the JSON
        file.
        AssertionError: The new start version is >= than the new end version.
    """

    if not tryjobs:
        return start, end, {}, {}

    # Verify that each tryjob has a value for the 'status' key.
    for cur_tryjob_dict in tryjobs:
        if not cur_tryjob_dict.get("status", None):
            raise ValueError(
                '"status" is missing or has no value, please '
                "go to %s and update it" % cur_tryjob_dict["link"]
            )

    all_bad_revisions = [end]
    all_bad_revisions.extend(
        cur_tryjob["rev"]
        for cur_tryjob in tryjobs
        if cur_tryjob["status"] == update_tryjob_status.TryjobStatus.BAD.value
    )

    # The minimum value for the 'bad' field in the tryjobs is the new end
    # version.
    bad_rev = min(all_bad_revisions)

    all_good_revisions = [start]
    all_good_revisions.extend(
        cur_tryjob["rev"]
        for cur_tryjob in tryjobs
        if cur_tryjob["status"] == update_tryjob_status.TryjobStatus.GOOD.value
    )

    # The maximum value for the 'good' field in the tryjobs is the new start
    # version.
    good_rev = max(all_good_revisions)

    # The good version should always be strictly less than the bad version;
    # otherwise, bisection is broken.
    assert (
        good_rev < bad_rev
    ), "Bisection is broken because %d (good) is >= " "%d (bad)" % (
        good_rev,
        bad_rev,
    )

    # Find all revisions that are 'pending' within 'good_rev' and 'bad_rev'.
    #
    # NOTE: The intent is to not launch tryjobs between 'good_rev' and 'bad_rev'
    # that have already been launched (this set is used when constructing the
    # list of revisions to launch tryjobs for).
    pending_revisions = {
        tryjob["rev"]
        for tryjob in tryjobs
        if tryjob["status"] == update_tryjob_status.TryjobStatus.PENDING.value
        and good_rev < tryjob["rev"] < bad_rev
    }

    # Find all revisions that are to be skipped within 'good_rev' and 'bad_rev'.
    #
    # NOTE: The intent is to not launch tryjobs between 'good_rev' and 'bad_rev'
    # that have already been marked as 'skip' (this set is used when
    # constructing the list of revisions to launch tryjobs for).
    skip_revisions = {
        tryjob["rev"]
        for tryjob in tryjobs
        if tryjob["status"] == update_tryjob_status.TryjobStatus.SKIP.value
        and good_rev < tryjob["rev"] < bad_rev
    }

    return good_rev, bad_rev, pending_revisions, skip_revisions


def GetCommitsBetween(
    start, end, parallel, src_path, pending_revisions, skip_revisions
):
    """Determines the revisions between start and end."""

    with get_llvm_hash.LLVMHash().CreateTempDirectory() as temp_dir:
        # We have guaranteed contiguous revision numbers after this,
        # and that guarnatee simplifies things considerably, so we don't
        # support anything before it.
        assert (
            start >= git_llvm_rev.base_llvm_revision
        ), f"{start} was too long ago"

        with get_llvm_hash.CreateTempLLVMRepo(temp_dir) as new_repo:
            if not src_path:
                src_path = new_repo
            index_step = (end - (start + 1)) // (parallel + 1)
            if not index_step:
                index_step = 1
            revisions = [
                rev
                for rev in range(start + 1, end, index_step)
                if rev not in pending_revisions and rev not in skip_revisions
            ]
            git_hashes = [
                get_llvm_hash.GetGitHashFrom(src_path, rev) for rev in revisions
            ]
            return revisions, git_hashes


def Bisect(
    revisions,
    git_hashes,
    bisect_state,
    last_tested,
    update_packages,
    chromeos_path,
    extra_change_lists,
    options,
    builder,
):
    """Adds tryjobs and updates the status file with the new tryjobs."""

    try:
        for svn_revision, git_hash in zip(revisions, git_hashes):
            tryjob_dict = modify_a_tryjob.AddTryjob(
                update_packages,
                git_hash,
                svn_revision,
                chromeos_path,
                extra_change_lists,
                options,
                builder,
                svn_revision,
            )

            bisect_state["jobs"].append(tryjob_dict)
    finally:
        # Do not want to lose progress if there is an exception.
        if last_tested:
            new_file = "%s.new" % last_tested
            with open(new_file, "w", encoding="utf-8") as json_file:
                json.dump(
                    bisect_state, json_file, indent=4, separators=(",", ": ")
                )

            os.rename(new_file, last_tested)


def LoadStatusFile(last_tested, start, end):
    """Loads the status file for bisection."""

    try:
        with open(last_tested, encoding="utf-8") as f:
            return json.load(f)
    except IOError as err:
        if err.errno != errno.ENOENT:
            raise

    return {"start": start, "end": end, "jobs": []}


def main(args_output):
    """Bisects LLVM commits.

    Raises:
        AssertionError: The script was run inside the chroot.
    """

    chroot.VerifyOutsideChroot()
    chroot.VerifyChromeOSRoot(args_output.chromeos_path)
    start = args_output.start_rev
    end = args_output.end_rev

    bisect_state = LoadStatusFile(args_output.last_tested, start, end)
    if start != bisect_state["start"] or end != bisect_state["end"]:
        raise ValueError(
            f"The start {start} or the end {end} version provided is "
            f'different than "start" {bisect_state["start"]} or "end" '
            f'{bisect_state["end"]} in the .JSON file'
        )

    # Pending and skipped revisions are between 'start_rev' and 'end_rev'.
    start_rev, end_rev, pending_revs, skip_revs = GetRemainingRange(
        start, end, bisect_state["jobs"]
    )

    revisions, git_hashes = GetCommitsBetween(
        start_rev,
        end_rev,
        args_output.parallel,
        args_output.src_path,
        pending_revs,
        skip_revs,
    )

    # No more revisions between 'start_rev' and 'end_rev', so
    # bisection is complete.
    #
    # This is determined by finding all valid revisions between 'start_rev'
    # and 'end_rev' and that are NOT in the 'pending' and 'skipped' set.
    if not revisions:
        if pending_revs:
            # Some tryjobs are not finished which may change the actual bad
            # commit/revision when those tryjobs are finished.
            no_revisions_message = (
                f"No revisions between start {start_rev} "
                f"and end {end_rev} to create tryjobs\n"
            )

            if pending_revs:
                no_revisions_message += (
                    "The following tryjobs are pending:\n"
                    + "\n".join(str(rev) for rev in pending_revs)
                    + "\n"
                )

            if skip_revs:
                no_revisions_message += (
                    "The following tryjobs were skipped:\n"
                    + "\n".join(str(rev) for rev in skip_revs)
                    + "\n"
                )

            raise ValueError(no_revisions_message)

        print(f"Finished bisecting for {args_output.last_tested}")
        if args_output.src_path:
            bad_llvm_hash = get_llvm_hash.GetGitHashFrom(
                args_output.src_path, end_rev
            )
        else:
            bad_llvm_hash = get_llvm_hash.LLVMHash().GetLLVMHash(end_rev)
        print(
            f"The bad revision is {end_rev} and its commit hash is "
            f"{bad_llvm_hash}"
        )
        if skip_revs:
            skip_revs_message = (
                "\nThe following revisions were skipped:\n"
                + "\n".join(str(rev) for rev in skip_revs)
            )
            print(skip_revs_message)

        if args_output.cleanup:
            # Abandon all the CLs created for bisection
            gerrit = os.path.join(
                args_output.chromeos_path, "chromite/bin/gerrit"
            )
            for build in bisect_state["jobs"]:
                try:
                    subprocess.check_output(
                        [gerrit, "abandon", str(build["cl"])],
                        stderr=subprocess.STDOUT,
                        encoding="utf-8",
                    )
                except subprocess.CalledProcessError as err:
                    # the CL may have been abandoned
                    if "chromite.lib.gob_util.GOBError" not in err.output:
                        raise

        return BisectionExitStatus.BISECTION_COMPLETE.value

    for rev in revisions:
        if (
            update_tryjob_status.FindTryjobIndex(rev, bisect_state["jobs"])
            is not None
        ):
            raise ValueError(f'Revision {rev} exists already in "jobs"')

    Bisect(
        revisions,
        git_hashes,
        bisect_state,
        args_output.last_tested,
        update_chromeos_llvm_hash.DEFAULT_PACKAGES,
        args_output.chromeos_path,
        args_output.extra_change_lists,
        args_output.options,
        args_output.builder,
    )


if __name__ == "__main__":
    sys.exit(main(GetCommandLineArgs()))
