"""Interactions with the db-contrib-tool bisect command."""
import logging
import os
import subprocess
import sys
from datetime import datetime, timedelta, timezone

import structlog

from db_contrib_tool.config import SetupReproEnvConfig
from db_contrib_tool.plugin import PluginInterface, Subcommand, SubcommandResult
from db_contrib_tool.services.evergreen_service import EvergreenService
from db_contrib_tool.utils import evergreen_conn

LOGGER = structlog.getLogger(__name__)

_HELP = """
Perform an evergreen-aware git-bisect to find the 'last passing version' and 'first failing version'
of mongo, with respect to a user provided shell script.
"""

_USAGE = """
The 'bisect' command lets a user specify a '--branch', '--variant' & '--lookback' period on which to
perform a bisect. The user also provides a shell test '--script' which exits with status code 0 to
indicate a successful test. The command performs the following steps:

(1) Get all versions for the given '--branch', '--variant' & '--lookback' period from Evergreen.
(2) Filter the versions for versions that Evergreen has binaries and artifacts for.
(3) Find the 'middle' version.
(4) Setup a test environment.
    - The 'build/resmoke-bisect' directory will have a sub directory --
    'build/resmoke-bisect/{version_id}' containing the git repo for this version.
    - The 'binaries' & 'artifacts' will also be downloaded to the directory named
    'build/resmoke-bisect/{version_id}'.
    - Create a virtual environment at 'build/resmoke-bisect/bisect_venv' and
    install packages for this version.
(5) Activate 'bisect_venv' & run the user provided shell script from within the
'build/resmoke-bisect/{version_id}' directory.
(6) Teardown the test environment.
(7) Repeat steps (3)-(6) on the left half, if (5) failed, or right half, if (5) succeeded.

This command will print the "Last Known Passing Version" & "First Known Failing Version".

NOTE: This 'bisect' command assumes a perfect partition between passing & failing versions.
ie: [Pass, Pass, Pass, Fail, Fail, Fail]
If there is not a perfect partition, try modifying the '--lookback' period or shell '--script'.
"""

_COMMAND = "bisect"

BISECT_DIR = os.path.dirname(os.path.abspath(__file__))
SETUP_TEST_ENV_SH = os.path.join(BISECT_DIR, "setup_test_env.sh")
TEARDOWN_TEST_ENV_SH = os.path.join(BISECT_DIR, "teardown_test_env.sh")
RUN_USER_SCRIPT_SH = os.path.join(BISECT_DIR, "run_user_script.sh")


def setup_logging(debug=False):
    """Enable logging."""
    log_level = logging.DEBUG if debug else logging.INFO
    logging.basicConfig(
        format="[%(asctime)s - %(name)s - %(levelname)s] %(message)s",
        level=log_level,
        stream=sys.stdout,
    )
    structlog.configure(logger_factory=structlog.stdlib.LoggerFactory())


class Bisect(Subcommand):
    """Main class for the Bisect subcommand."""

    def __init__(
        self,
        branch,
        lookback,
        variant,
        script,
        python_installation,
        evergreen_config=None,
        debug=None,
    ):
        """Initialize."""
        setup_logging(debug)
        self.branch = branch
        self.lookback = lookback
        self.variant = variant
        self.script = script
        self.python_installation = python_installation
        self.evergreen_config = evergreen_config
        self.evg_api = evergreen_conn.get_evergreen_api(evergreen_config)
        self.evg_service = EvergreenService(self.evg_api, SetupReproEnvConfig.empty_config())

    @staticmethod
    def _teardown_test_env(version):
        """Remove any directories added during testing process."""
        try:
            subprocess.run(["bash", TEARDOWN_TEST_ENV_SH], check=True)
        except subprocess.CalledProcessError as err:
            LOGGER.error("Could not teardown test environment for bisect.", version=version)
            raise err
        LOGGER.info("Completed teardown of test environment for bisect.", version=version)

    def _setup_test_env(self, version):
        """Set up a test environment for the given version."""
        try:
            subprocess.run(
                [
                    "bash",
                    SETUP_TEST_ENV_SH,
                    self.python_installation,
                    self.evergreen_config if self.evergreen_config else "",
                    self.variant,
                    version,
                ],
                check=True,
            )
        except subprocess.CalledProcessError:
            LOGGER.error(
                "Could not setup test environment for bisect -- retrying.", version=version
            )
            try:
                subprocess.run(
                    [
                        "bash",
                        SETUP_TEST_ENV_SH,
                        self.python_installation,
                        self.evergreen_config if self.evergreen_config else "",
                        self.variant,
                        version,
                    ],
                    check=True,
                )
            except subprocess.CalledProcessError as err:
                LOGGER.error("Could not setup test environment for bisect.", version=version)
                raise err
        LOGGER.info("Completed setup of test environment for bisect.", version=version)

    def _run_user_script(self, version):
        """Run the user script in a virtual environment."""
        return subprocess.run(
            ["bash", RUN_USER_SCRIPT_SH, version, self.script], check=False
        ).returncode

    def _test_version_with_script(self, version):
        """Test the given version with the user provided script."""
        self._setup_test_env(version)
        success = self._run_user_script(version)
        self._teardown_test_env(version)
        return success == 0

    def bisect(self, versions):
        """Bisect to find latest passing version assuming ordered by oldest to latest version."""
        if len(versions) == 0:
            return None
        midpoint = len(versions) // 2
        success = self._test_version_with_script(versions[midpoint])
        # if success, keep checking right
        if success:
            LOGGER.info(
                "Version passed user script.",
                version=versions[midpoint],
            )
            return self.bisect(versions[midpoint + 1 :]) or versions[midpoint]
        # if fail, keep checking left
        else:
            LOGGER.info("Version failed user script.", version=versions[midpoint])
            return self.bisect(versions[0:midpoint])

    def find_versions_with_binaries(self):
        """
        Find versions that have binaries for the user provided variant in the lookback period.

        :return: List of versions that have binaries ordered from earliest to latest.
        """
        versions_with_binaries = []
        for version in self.evg_api.versions_by_project_time_window(
            project_id=f'mongodb-mongo-{"" if self.branch == "master" else "v"}{self.branch}',
            before=datetime.now(timezone.utc),
            after=datetime.now(timezone.utc) - timedelta(self.lookback),
        ):
            urls = self.evg_service.get_compile_artifact_urls(
                version, self.variant, ignore_failed_push=True
            )
            if urls is not None and urls.urls.get("Artifacts") and urls.urls.get("Binaries"):
                versions_with_binaries.append(version.revision)

        versions_with_binaries.reverse()
        return versions_with_binaries

    def execute(self):
        """
        Perform bisect for the provided branch, variant & lookback period based on result of script.

        Print the last passing version and first failing version.
        """
        versions_with_binaries = self.find_versions_with_binaries()

        # No versions found
        if not versions_with_binaries:
            LOGGER.info(
                "No versions with binaries found for given branch, variant & lookback period.",
                branch=self.branch,
                variant=self.variant,
                lookback=self.lookback,
            )
            return SubcommandResult.SUCCESS

        LOGGER.info(
            "Performing bisect on the following versions: ", versions=versions_with_binaries
        )

        last_passing_version = self.bisect(versions_with_binaries)
        first_failing_version = None

        # All versions failed
        if last_passing_version is None:
            first_failing_version = versions_with_binaries[0]
            LOGGER.info("All versions in lookback period failed.")

        # All versions passed
        elif last_passing_version == versions_with_binaries[-1]:
            LOGGER.info("All versions in lookback period passed.")

        else:
            first_failing_version = versions_with_binaries[
                versions_with_binaries.index(last_passing_version) + 1
            ]

        print(f"Last Known Passing Version: {last_passing_version}")
        print(f"First Known Failing Version: {first_failing_version}")
        return SubcommandResult.SUCCESS


class BisectPlugin(PluginInterface):
    """Interact with Resmoke bisect."""

    TOOLCHAIN_PYTHON3 = "/opt/mongodbtoolchain/v3/bin/python3"

    @classmethod
    def _add_args_to_parser(cls, parser):
        parser.add_argument(
            "--lookback",
            "-l",
            action="store",
            type=int,
            default=365,
            help="Maximum number of days to look back for versions to test.",
        )
        parser.add_argument(
            "--branch",
            "-b",
            action="store",
            type=str,
            required=True,
            help="The branch for which versions are being tested. [REQUIRED]",
        )
        parser.add_argument(
            "--variant",
            "-v",
            action="store",
            type=str,
            required=True,
            help="The variant for which versions are being tested. [REQUIRED]",
        )
        parser.add_argument(
            "--script",
            "-s",
            action="store",
            type=str,
            required=True,
            help="Location of the shell test script to run on the versions. [REQUIRED]",
        )
        parser.add_argument(
            "--python-installation",
            "-p",
            action="store",
            type=str,
            dest="python_installation",
            default="python3",
            required=False,
            help="Location of a python installation to use for shell commands. If not specified, "
            f"'python3' or '{cls.TOOLCHAIN_PYTHON3}' (if toolchain is setup) will be used.",
        )
        parser.add_argument(
            "-ec",
            "--evergreenConfig",
            dest="evergreen_config",
            help="Location of evergreen configuration file. If not specified it will look "
            f"for it in the following locations: {evergreen_conn.EVERGREEN_CONFIG_LOCATIONS}",
        )
        parser.add_argument(
            "-d",
            "--debug",
            dest="debug",
            action="store_true",
            default=False,
            help="Set DEBUG logging level.",
        )

    def add_subcommand(self, subparsers):
        """
        Add 'bisect' subcommand.

        :param subparsers: argparse parser to add to.
        """
        parser = subparsers.add_parser(_COMMAND, usage=_USAGE, help=_HELP)
        self._add_args_to_parser(parser)

    @classmethod
    def _update_args(cls, args):
        """Update command-line arguments."""
        if os.path.isfile(cls.TOOLCHAIN_PYTHON3):
            args.python_installation = cls.TOOLCHAIN_PYTHON3

    def parse(self, subcommand, parser, parsed_args, **kwargs):
        """
        Return bisect if command is one we recognize.

        :param subcommand: equivalent to parsed_args.command
        :param parser: parser used
        :param parsed_args: output of parsing
        :param kwargs: additional args
        :return: None or a Subcommand
        """
        if subcommand != _COMMAND:
            return None

        args = parsed_args
        self._update_args(args)

        return Bisect(
            branch=args.branch,
            lookback=args.lookback,
            evergreen_config=args.evergreen_config,
            variant=args.variant,
            script=args.script,
            debug=args.debug,
            python_installation=args.python_installation,
        )
