import argparse
import git
import os
import re
import six.moves.configparser
import subprocess

repo_dir = os.path.abspath(os.path.join(__file__, "..", "..", "..", ".."))


class RunAllTestsException(Exception):
    """Raised to indicate that ALL tests should be run"""


class RunNoTestsException(Exception):
    """Raised to indicate that NO tests should be run"""


class Strategy:

    wildcard = False  # if True executes full test suite on trigger match
    triggers = set()
    packages = set()
    tests = set()

    def __init__(self, name):
        self.name = name

    def __call__(self, whatchanged, verbose=True):
        for trigger in self.triggers:
            matcher = re.compile(trigger)
            for path in whatchanged:
                if matcher.search(path):
                    if verbose:
                        marker = self.wildcard and "* " or "- "
                        print(
                            "{}[{:_^12}] triggered by {}".format(
                                marker, self.name, path
                            )
                        )
                    if self.wildcard:
                        raise RunAllTestsException(path)
                    return (set(self.packages), set(self.tests))
        return (set(), set())

    def match(self, changeset):
        matchers = [re.compile(trigger) for trigger in self.triggers]
        matching = set()
        for path in changeset:
            for matcher in matchers:
                if matcher.match(path):
                    matching.add(path)
        return matching

    def __repr__(self):
        return f"<Strategy:{self.name}>"


class Policy:

    strategies = []

    def __init__(self, configpath=None):
        if configpath:
            self.read_config(configpath)

    def add(self, *strategies):
        self.strategies.extend(strategies)

    def read_config(self, filepath):
        config = six.moves.configparser.ConfigParser()
        config.read(filepath)
        if "policy" not in config.sections():
            raise ValueError(f"Required [policy] section missing in {filepath}")

        def safe_get(config, sectionid, varname):
            try:
                return config.get(sectionid, varname).strip().split()
            except six.moves.configparser.NoOptionError:
                return []

        for id in safe_get(config, "policy", "strategies"):
            strategy = Strategy(id)
            strategy.triggers = safe_get(config, id, "triggers")
            strategy.packages = safe_get(config, id, "packages")
            strategy.tests = safe_get(config, id, "tests")
            try:
                strategy.wildcard = config.getboolean(id, "wildcard")
            except six.moves.configparser.NoOptionError:
                pass  # defaults to False anyway
            self.add(strategy)

    def __call__(self, whatchanged, verbose=True):
        matching = set()
        for strategy in self.strategies:
            matching = matching.union(strategy.match(whatchanged))
        if matching != whatchanged:
            if verbose:
                print("Changes detected outside of optimization strategies:")
                for path in whatchanged - matching:
                    print(f"  {path}")
                print("Running all tests.")
            return (set(), set())
        # only if each path had a match do we dare to optimize
        else:
            return self.optimized(whatchanged, verbose)

    def optimized(self, whatchanged, verbose=True):
        packages = set()
        tests = set()
        for strategy in self.strategies:
            try:
                _packages, _tests = strategy(whatchanged, verbose)
            except RunAllTestsException:
                return (set(), set())
            packages = packages.union(_packages)
            tests = tests.union(_tests)
        return (packages, tests)


def spec(operator, selectors):
    return " ".join(f"{operator} {item}" for item in sorted(selectors))


def main():
    configpath = os.path.join(os.path.dirname(__file__), "policy.cfg")
    epilog = "You should schedule two separate runs: --run 1 and --run 2"
    parser = argparse.ArgumentParser("fastest", epilog=epilog)
    parser.add_argument(
        "-c",
        "--config",
        action="store_true",
        help=f"config file to use (default: {configpath})",
    )
    parser.add_argument("-r", "--run", type=int, help="which run to make: < 1 | 2 > ")
    parser.add_argument("--head", help="Git ref to test (default: HEAD)")
    parser.add_argument("--base", help="Git ref to diff against (default: master)")
    parser.add_argument(
        "-n",
        "--dry-run",
        action="store_true",
        help="do everything except actually executing tests",
    )
    parser.add_argument("-q", "--quiet", action="store_true", help="suppress output")
    args = parser.parse_args()
    verbose = not args.quiet
    runid = getattr(args, "run") or 0
    if runid not in [0, 1, 2]:
        raise ValueError(f"Invalid run argument: {runid}")
    config = getattr(args, "config") or configpath
    head = getattr(args, "head") or "HEAD"
    base = getattr(args, "base") or "origin/master"
    dryrun = args.dry_run
    if verbose:
        print(f"Fastest run:{runid} using config file {config}")
        print(f"Calculating changes in {head} that are not in {base}")
    try:
        changes = whatchanged(head, base, verbose)
        policy = Policy(config)
        packages, tests = policy(changes, verbose=verbose)
        spec_pkg = spec("-s", packages)
        spec_tst = spec("-t", tests)
    except RunAllTestsException:
        packages = tests = None

    if packages and tests and runid in [0, 1]:
        return run_multi([spec_pkg, spec_tst], verbose=verbose, dryrun=dryrun)
    elif packages and runid in [0, 1]:
        return run(spec_pkg, verbose=verbose, dryrun=dryrun)
    elif tests and runid in [0, 1]:
        return run(spec_tst, verbose=verbose, dryrun=dryrun)
    elif packages or tests and runid == 2:
        # optimized run, we don't need the second run
        if verbose:
            print("Optimization OK, tests in run 1, nothing to do in run 2.")
        return 0

    # no optimization
    if not packages and not tests:
        if runid == 0:
            # jenkins combined run
            return run("", verbose=verbose, dryrun=dryrun)
        elif runid == 1:
            # gitlab run 1 of 2
            return run("-t '!robot'", verbose=verbose, dryrun=dryrun)
        else:
            # gitlab run 2 of 2
            return run("-t 'robot'", verbose=verbose, dryrun=dryrun)


def repo():
    """In case of a non-git (egg) release tests will be disabled"""
    return git.Repo(repo_dir)


def whatchanged(commitid, baseid=None, verbose=True):
    if not baseid:
        raise RunAllTestsException("No base to compare against")
    repo().git.fetch()  # update master index even if master sandbox is behind
    commit = repo().commit(commitid)
    base = repo().commit(baseid)
    # GOTCHA: what looks like a neat sequential history turns out to
    # be a more complex parenting history when merge commits are involved
    commit_and_parents = [commit]
    commit_parent_generator = commit.iter_parents()
    base_and_parents = [base]
    base_parent_generator = base.iter_parents()
    shared_ancestors = []
    commit_history_exhausted = base_history_exhausted = False
    # dig up some history to improve detection of already-merged commits
    for i in range(1000):
        try:
            base_and_parents.append(next(base_parent_generator))
        except StopIteration:
            base_history_exhausted = True
    # do the same on HEAD to detect older feature branch changes
    # even if master got merged into branch meanwhile
    for i in range(1000):
        try:
            commit_and_parents.append(next(commit_parent_generator))
        except StopIteration:
            commit_history_exhausted = True
    # walk back both branches one step here, one step there
    while not commit_history_exhausted or not base_history_exhausted:
        if not commit_history_exhausted:
            try:
                commit_and_parents.append(next(commit_parent_generator))
            except StopIteration:
                commit_history_exhausted = True
        if not base_history_exhausted:
            try:
                base_and_parents.append(next(base_parent_generator))
            except StopIteration:
                base_history_exhausted = True
        # massive speedup: stop backtracking on shared parent
        shared_ancestors = [x for x in commit_and_parents if x in base_and_parents]
        if shared_ancestors:
            break
    if not shared_ancestors:
        raise RunAllTestsException("No shared ancestors")
    # only test changes in HEAD that are not yet in master
    new_commits = [x for x in commit_and_parents if x not in base_and_parents]
    if not new_commits:
        # run full suite when testing master against master
        raise RunAllTestsException("Nothing changed, running full suite.")
    if verbose:
        print(f"Found {len(new_commits)} new commits")
        if len(new_commits) <= 20:
            for _c in new_commits:
                print(f"    {_c.hexsha:.10}   {_c.summary:.50}")
    skip_commits = [x for x in new_commits if "[ci skip]" in x.message]
    if skip_commits and verbose:
        print(f"Skipping {len(skip_commits)} commits marked as [ci skip]")
    if skip_commits and new_commits == skip_commits:
        # only happens on a branch with minimal changes
        raise RunNoTestsException(
            "All changes marked as [ci skip], skipping all tests."
        )
    commit_diffs = [x.diff(x.parents[0]) for x in new_commits if x not in skip_commits]
    # each commit diff contains an iterable of file diffs
    file_diffs = [f for c in commit_diffs for f in c]
    return {x.a_path or x.b_path for x in file_diffs}


def run(testspec, verbose=True, dryrun=False):
    # this is a bit of a hack to support both src and egg builds
    # and both bin/fastest and bin/test invocations
    buildout_dir = os.getcwd().replace("/parts/test", "")
    testrunner = os.path.join(buildout_dir, "bin", "test")
    command = f"{testrunner} {testspec}"
    if verbose:
        marker = dryrun and "Dryrun" or "Subprocess"
        print(f"{marker}: {command}")
    if dryrun:
        return 0
    try:
        with subprocess.Popen(command, shell=True, stdout=subprocess.PIPE) as process:
            while True:
                output = process.stdout.readline()
                if output and verbose:
                    print(output.strip())
                if process.poll() is not None:
                    break
            return process.poll()
    except subprocess.CalledProcessError as exc:
        if verbose:
            print("ERROR: return code {}")
        return exc.returncode


def run_multi(speclist, verbose=True, dryrun=False):
    status = 0
    for testspec in speclist:
        status += run(testspec, verbose, dryrun)
    return status
