# coding=utf-8
#
# This file is part of Hypothesis, which may be found at
# https://github.com/HypothesisWorks/hypothesis/
#
# Most of this work is copyright (C) 2013-2019 David R. MacIver
# (david@drmaciver.com), but it contains contributions by others. See
# CONTRIBUTING.rst for a full list of people who may hold copyright, and
# consult the git log if you need to determine who owns an individual
# contribution.
#
# This Source Code Form is subject to the terms of the Mozilla Public License,
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
# obtain one at https://mozilla.org/MPL/2.0/.
#
# END HEADER

from __future__ import absolute_import, division, print_function

from hypothesis.internal.compat import int_from_bytes, int_to_bytes
from hypothesis.internal.conjecture.data import ConjectureData, Status
from hypothesis.internal.conjecture.datatree import PreviouslyUnseenBehaviour
from hypothesis.internal.conjecture.engine import (
    BUFFER_SIZE,
    NO_SCORE,
    GenerationParameters,
)


class Optimiser(object):
    """A fairly basic optimiser designed to increase the value of scores for
    targeted property based testing.

    This implements a fairly naive hill climbing algorithm based on randomly
    regenerating parts of the test case to attempt to improve the result. It is
    not expected to produce amazing results, because it is designed to be run
    in a fairly small testing budget, so it prioritises finding easy wins and
    bailing out quickly if that doesn't work.

    For more information about targeted property-based testing, see
    Löscher, Andreas, and Konstantinos Sagonas. "Targeted property-based
    testing." Proceedings of the 26th ACM SIGSOFT International Symposium on
    Software Testing and Analysis. ACM, 2017.
    """

    def __init__(self, engine, data, target):
        self.engine = engine
        self.current_data = data
        self.target = target
        self.improved = False

    def run(self):
        self.hill_climb()

    def score_function(self, data):
        return data.target_observations.get(self.target, NO_SCORE)

    @property
    def current_score(self):
        return self.score_function(self.current_data)

    @property
    def random(self):
        return self.engine.random

    def consider_new_test_data(self, data):
        """Consider a new data object as a candidate target. If it is better
        than the current one, return True."""
        if data.status < Status.VALID:
            return False
        score = self.score_function(data)
        if score < self.current_score:
            return False
        if score > self.current_score:
            self.improved = True
            self.current_data = data
            return True
        return False

    def consider_new_buffer(self, buffer):
        return self.consider_new_test_data(self.engine.cached_test_function(buffer))

    def hill_climb(self):
        """Run hill climbing. Our hill climbing algorithm relies on selecting
        an example to improve. We try multiple example selection strategies to
        try to find one that works well."""

        def last_example(d):
            """Select the last non-empty example. This is particularly good for
            lists and other things which implement a logic of continuing until
            some condition is made."""
            i = len(d.examples) - 1
            while d.examples[i].length == 0:
                i -= 1
            return i

        def random_non_empty(d):
            """Select any non-empty example, uniformly at random."""
            while True:
                i = self.random.randrange(0, len(d.examples))
                if d.examples[i].length > 0:  # pragma: no branch  # flaky coverage :/
                    return i

        self.do_hill_climbing(last_example)
        self.do_hill_climbing(random_non_empty)

    def do_hill_climbing(self, select_example):
        """The main hill climbing loop where we actually do the work: Take
        data, and attempt to improve its score for target. select_example takes
        a data object and returns an index to an example where we should focus
        our efforts."""

        # The idea of this code is that we expect (possibly incorrectly, but
        # the correctness of this code is not dependent on this assumption,
        # only how useful it is) that the score will be either increasing or
        # decreasing when measured as a function of any given example in the
        # shortlex order. This is likely true in some "local" sense even if it
        # is not true globally.
        #
        # If we were to generate a replacement uniformly at random then we
        # would lose out when the current value of the example is very near to
        # its maximum or minimum value, so instead we either draw a larger or
        # smaller value. When this fails to work we switch directions, so we
        # still get a reasonable spread, but this allows us to more reliably
        # move in the right direction.
        upwards = True

        # Often when regenerating we will "run off the end", requiring more
        # random data than we started with. When that happens we use this
        # parameter to regenerate it.
        parameter = GenerationParameters(self.random)

        # We keep running our hill climbing until we've got (fairly weak)
        # evidence that we're at a local maximum.
        max_failures = 10
        consecutive_failures = 0
        while (
            consecutive_failures < max_failures
            # Once we've hit and interesting target it's time to stop hill
            # climbing because we don't really care about maximizing the score
            # further.
            and self.current_data.status <= Status.VALID
        ):
            if self.attempt_to_improve(
                parameter=parameter,
                example_index=select_example(self.current_data),
                upwards=upwards,
            ):
                # If we succeeed at improving the score then we no longer have
                # any evidence that we're at a local maximum so we reset the
                # count.
                consecutive_failures = 0
            else:
                # If we've failed in our hill climbing attempt, this could be
                # for two reasons: We're moving in the wrong direction, or our
                # current choice of parameter is not a good one for generating
                # the extensions. We reset both of them in the hope of making
                # more progress next time around.
                parameter = GenerationParameters(self.random)
                upwards = not upwards
                consecutive_failures += 1

    def attempt_to_improve(self, example_index, parameter, upwards):
        """Part of our hill climbing implementation. Attempts to improve a
        given score by regenerating an example in the data based on a new
        parameter."""

        data = self.current_data
        self.current_score

        ex = data.examples[example_index]
        assert ex.length > 0
        prefix = data.buffer[: ex.start]
        suffix = data.buffer[ex.end :]

        existing = data.buffer[ex.start : ex.end]

        existing_as_int = int_from_bytes(existing)
        max_int_value = (256 ** len(existing)) - 1

        if existing_as_int == max_int_value and upwards:
            return False

        if existing_as_int == 0 and not upwards:
            return False

        # We make a mix of small and large jumps. Neither are guaranteeed to
        # work, but each will work in circumstances the other might not. Small
        # jumps are especially important for the last steps towards a local
        # maximum - often large jumps will break some important property of the
        # test case while small, more careful, jumps will not. On the flip side
        # we sometimes end up in circumstances where small jumps don't work
        # but a random draw will produce a good result with reasonable
        # probability, and we don't want to be too timid in our optimisation as
        # it will take too long to complete.
        if self.random.randint(0, 1):
            if upwards:
                replacement_range = (existing_as_int + 1, max_int_value)
            else:
                replacement_range = (0, existing_as_int - 1)
            replacement_as_int = self.random.randint(*replacement_range)
        elif upwards:
            replacement_as_int = existing_as_int + 1
        else:
            replacement_as_int = existing_as_int - 1

        replacement = int_to_bytes(replacement_as_int, len(existing))

        new_buffer = prefix + replacement + suffix

        dummy = ConjectureData(
            prefix=new_buffer, parameter=parameter, max_length=BUFFER_SIZE,
        )
        try:
            self.engine.tree.simulate_test_function(dummy)
            # If this didn't throw an exception then we've already seen this
            # behaviour before and are trying something too similar to what
            # we already have.
            return False
        except PreviouslyUnseenBehaviour:
            pass

        attempt = self.engine.new_conjecture_data(
            prefix=max(dummy.buffer, new_buffer, key=len), parameter=parameter
        )
        self.engine.test_function(attempt)
        if self.consider_new_test_data(attempt):
            return True

        if attempt.status < Status.VALID:
            return False

        ex_attempt = attempt.examples[example_index]

        replacement = attempt.buffer[ex_attempt.start : ex_attempt.end]

        return self.consider_new_buffer(prefix + replacement + suffix)
