#!/usr/bin/env python

'''A helper script for bisecting common problems when working with ArduPilot

Bisect between a commit which builds and one which doesn't,
finding the first commit which broke the build with a
 specific failure:

git bisect reset
git bisect good a7647e77d9
git bisect bad 153ad9539866f8d93a99e9998118bb090d2f747f
cp -a Tools/autotest/bisect-helper.py /tmp
git bisect run /tmp/bisect-helper.py --build \
     --build-failure-string= \
     "reference to 'OpticalFlow' is ambiguous"

Work out who killed bebop:
cp -a Tools/autotest/bisect-helper.py /tmp
git bisect reset
git bisect good a7647e77d9 &&
  git bisect bad 153ad9539866f8d93a99e9998118bb090d2f747f &&
  git bisect run /tmp/bisect-helper.py --build \
    --waf-configure-arg="--board bebop"

# Use a failing test to work out which commit broke things:
cp Tools/autotest/bisect-helper.py /tmp
git bisect reset
git bisect start
git bisect bad
git bisect good HEAD~1024
time git bisect run /tmp/bisect-helper.py --autotest --autotest-vehicle=Plane --autotest-test=NeedEKFToArm --autotest-branch=wip/bisection-using-named-test

Work out who overflowed Omnbusf4pro:
cp -a Tools Tools2
GOOD=c4ce6fa3851f93df34393c376fee5b37e0a270d2
BAD=f00bf77af75f828334f735580d6b19698b639a74
BFS="overflowed by"
git bisect reset
git bisect start
git bisect good $GOOD &&
  git bisect bad $BAD &&
  git bisect run Tools2/autotest/bisect-helper.py --build \
    --waf-configure-arg="--board OmniBusF4Pro" \
     --build-failure-string="$BFS"

# Use a flapping test to work out which commit broke things.  The
# "autotest-branch" is the branch containing the flapping test (which
# may be master)
rm /tmp/bisect-debug/*; git commit -m "stuff" -a ; cp Tools/autotest/bisect-helper.py /tmp; git bisect reset; git bisect start; git bisect bad d24e569b20; git bisect good 3f6fd49507f286ad8f6ccc9e29b110d5e9fc9207^
time git bisect run /tmp/bisect-helper.py --autotest --autotest-vehicle=Copter --autotest-test=Replay --autotest-branch=wip/bisection-using-flapping-test --autotest-test-passes=40 --autotest-failure-require-string="Mismatch in field XKF1.Pitch" --autotest-failure-ignore-string="HALSITL::SITL_State::_check_rc_input"

'''

import optparse
import os
import subprocess
import shlex
import sys
import time
import traceback

def get_exception_stacktrace(e):
    if sys.version_info[0] >= 3:
        ret = "%s\n" % e
        ret += ''.join(traceback.format_exception(etype=type(e),
                                                  value=e,
                                                  tb=e.__traceback__))
        return ret
    return traceback.format_exc(e)

class Bisect(object):
    def __init__(self, opts):
        self.opts = opts

    def exit_skip_code(self):
        return 125

    def exit_pass_code(self):
        return 0

    def exit_fail_code(self):
        return 1

    def exit_abort_code(self):
        return 129

    def exit_skip(self):
        self.progress("SKIP")
        sys.exit(self.exit_skip_code())

    def exit_pass(self):
        self.progress("PASS")
        sys.exit(self.exit_pass_code())

    def exit_fail(self):
        self.progress("FAIL")
        sys.exit(self.exit_fail_code())

    def exit_abort(self):
        '''call when this harness has failed (e.g. to reset to required
        state)'''
        self.progress("ABORT")
        sys.exit(self.exit_abort_code())

    def progress(self, string):
        '''pretty-print progress'''
        print("BH: %s" % string)

    def run_program(self, prefix, cmd_list):
        '''copied in from build_binaries.py'''
        '''run cmd_list, spewing and setting output in self'''
        self.progress("Running (%s)" % " ".join(cmd_list))
        p = subprocess.Popen(cmd_list,
                             bufsize=1,
                             stdin=None,
                             close_fds=True,
                             stdout=subprocess.PIPE,
                             stderr=subprocess.STDOUT)
        self.program_output = ""
        while True:
            x = p.stdout.readline()
            if len(x) == 0:
                returncode = os.waitpid(p.pid, 0)
                if returncode:
                    break
                    # select not available on Windows... probably...
                time.sleep(0.1)
                continue
            if type(x) == bytes:
                x = x.decode('utf-8')
            self.program_output += x
            x = x.rstrip()
            print("%s: %s" % (prefix, x))
        (_, status) = returncode
        if status != 0:
            self.progress("Process failed (%s)" %
                          str(returncode))
            raise subprocess.CalledProcessError(
                returncode, cmd_list)

    def build(self):
        '''run ArduCopter build.  May exit with skip or fail'''
        self.run_program("WAF-clean", ["./waf", "clean"])
        cmd_configure = ["./waf", "configure"]
        pieces = [shlex.split(x)
                  for x in self.opts.waf_configure_args]
        for piece in pieces:
            cmd_configure.extend(piece)
        self.run_program("WAF-configure", cmd_configure)
        cmd_build = ["./waf", "build"]
        pieces = [shlex.split(x)
                  for x in self.opts.waf_build_args]
        for piece in pieces:
            cmd_build.extend(piece)
        try:
            self.run_program("WAF-build", cmd_build)
        except subprocess.CalledProcessError as e:
            # well, it definitely failed....
            if self.opts.build_failure_string is not None:
                if self.opts.build_failure_string in self.program_output:
                    self.progress("Found relevant build failure")
                    self.exit_fail()
                # it failed, but not for the reason we're looking
                # for...
                self.exit_skip()
            else:
                self.exit_fail()


class BisectBuild(Bisect):

    def __init__(self, opts):
        super(BisectBuild, self).__init__(opts)

    def run(self):
        self.build()  # may exit with skip or fail
        self.exit_pass()


class BisectCITest(Bisect):

    def __init__(self, opts):
        super(BisectCITest, self).__init__(opts)

    def autotest_script(self):
        return os.path.join("Tools", "autotest", "autotest.py")


    def git_reset(self):
        try:
            self.run_program("Reset autotest directory", ["git", "reset", "--hard"])
        except subprocess.CalledProcessError as e:
            self.exit_abort()

    def get_current_hash(self):
        self.run_program("Get current hash", ["git", "rev-parse", "HEAD"])
        x = self.program_output
        return x.strip()

    def run(self):

        current_hash = self.get_current_hash()

        self.debug_dir = os.path.join("/tmp", "bisect-debug")
        if not os.path.exists(self.debug_dir):
            os.mkdir(self.debug_dir)

        if self.opts.autotest_branch is None:
            raise ValueError("expected autotest branch")

        try:
            self.run_program("Update submodules",
                             ["git", "submodule", "update", "--init", "--recursive"])
        except subprocess.CalledProcessError as e:
            self.exit_abort()

        try:
            self.run_program("Check autotest directory out from master",
                             ["git", "checkout", self.opts.autotest_branch, "Tools/autotest"])
        except subprocess.CalledProcessError as e:
            self.exit_abort()


        self.progress("Build")
        cmd = [self.autotest_script()]
        if self.opts.autotest_valgrind:
            cmd.append("--debug")
        cmd.append("build.%s" % self.opts.autotest_vehicle)
        print("build cmd: %s" % str(cmd))

        try:
            self.run_program("Run autotest (build)", cmd)
        except subprocess.CalledProcessError as e:
            self.git_reset()
            self.exit_skip()

        cmd = [self.autotest_script()]
        if self.opts.autotest_valgrind:
            cmd.append("--valgrind")
        cmd.append("test.%s.%s" % (self.opts.autotest_vehicle, self.opts.autotest_test))

        code = self.exit_pass_code()
        for i in range(0, self.opts.autotest_test_passes):
            ignore = False
            try:
                self.run_program(
                    "Run autotest (%u/%u)" % (i+1, self.opts.autotest_test_passes),
                    cmd)
            except subprocess.CalledProcessError as e:
                for ignore_string in self.opts.autotest_failure_ignore_string:
                    if ignore_string in self.program_output:
                        self.progress("Found ignore string (%s) in program output" % ignore_string)
                        ignore = True
                if not ignore and self.opts.autotest_failure_require_string is not None:
                    if self.opts.autotest_failure_require_string not in self.program_output:
                        # it failed, but not for the reason we're looking
                        # for...
                        self.progress("Did not find test failure string (%s); skipping" % self.opts.autotest_failure_require_string)
                        code = self.exit_skip_code()
                        break
                if not ignore:
                    code = self.exit_fail_code()

            with open(os.path.join(self.debug_dir, "run-%s-%u.txt" % (current_hash, i+1)), "w") as f:
                f.write(self.program_output)

            if code == self.exit_fail_code():
                with open("/tmp/fail-counts", "a") as f:
                    print("Failed on run %u" % (i+1,), file=f)
            if ignore:
                self.progress("Ignoring this run")
                continue
            if code != self.exit_pass_code():
                break

        self.git_reset()

        sys.exit(code)


if __name__ == '__main__':

    parser = optparse.OptionParser("bisect.py ")
    parser.add_option("--build",
                      action='store_true',
                      default=False,
                      help="Help bisect a build failure")
    parser.add_option("--build-failure-string",
                      type='string',
                      default=None,
                      help="If supplied, must be present in"
                      "build output to count as a failure")

    group_autotest = optparse.OptionGroup(parser, "Run-AutoTest Options")
    group_autotest.add_option("--autotest",
                              action='store_true',
                              default=False,
                              help="Bisect a failure with an autotest test")
    group_autotest.add_option("", "--autotest-vehicle",
                              dest="autotest_vehicle",
                              type="string",
                              default="ArduCopter",
                              help="Which vehicle to run tests for")
    group_autotest.add_option("", "--autotest-test",
                              dest="autotest_test",
                              type="string",
                              default="NavDelayAbsTime",
                              help="Test to run to find failure")
    group_autotest.add_option("", "--autotest-valgrind",
                              dest="autotest_valgrind",
                              action='store_true',
                              default=False,
                              help="Run autotest under valgrind")
    group_autotest.add_option("", "--autotest-test-passes",
                              dest="autotest_test_passes",
                              type=int,
                              default=1,
                              help="Number of times to run test before declaring it is good")
    group_autotest.add_option("", "--autotest-branch",
                              dest="autotest_branch",
                              type="string",
                              help="Branch on which the test exists.  The autotest directory will be reset to this branch")
    group_autotest.add_option("--autotest-failure-require-string",
                             type='string',
                             default=None,
                             help="If supplied, must be present in"
                             "test output to count as a failure")
    group_autotest.add_option("--autotest-failure-ignore-string",
                              type='string',
                              default=[],
                              action="append",
                              help="If supplied and present in"
                              "test output run will be ignored")

    group_build = optparse.OptionGroup(parser, "Build options")
    group_build.add_option("", "--waf-configure-arg",
                           action="append",
                           dest="waf_configure_args",
                           type="string",
                           default=["--board skyviper-v2450"],
                           help="extra arguments to pass to"
                           "waf in configure step")
    group_build.add_option("", "--waf-build-arg",
                           action="append",
                           dest="waf_build_args",
                           type="string",
                           default=["--target bin/arducopter"],
                           help="extra arguments to pass"
                           "to waf in its build step")

    parser.add_option_group(group_build)

    (opts, args) = parser.parse_args()

    if opts.build:
        bisecter = BisectBuild(opts)
    elif opts.autotest:
        bisecter = BisectCITest(opts)
    else:
        raise ValueError("Not told how to bisect")

try:
    bisecter.run()
except Exception as e:
    print("Caught exception in bisect-helper: %s" % str(e))
    print(get_exception_stacktrace(e))
    sys.exit(129)  # should abort the bisect process
