"""
Utilities for helping with the analysis of data, for reporting purposes.
"""
from lnt.testing import FAIL
from lnt.util import logger
from lnt.util import multidict
from lnt.util import stats

REGRESSED = 'REGRESSED'
IMPROVED = 'IMPROVED'
UNCHANGED_PASS = 'UNCHANGED_PASS'
UNCHANGED_FAIL = 'UNCHANGED_FAIL'

# The smallest measureable change we can detect in seconds.
MIN_VALUE_PRECISION = 0.0001

# Minimal percentage difference that is visible in reports
MIN_PERCENTAGE_CHANGE = .01


def absmin_diff(current, prevs):
    """Min of differences between current sample and all previous samples.
    Given more than one min, use the last one detected which is probably a
    newer value. Returns (difference, prev used)
    """
    diffs = [abs(current-prev) for prev in prevs]
    smallest_pos = 0
    smallest = diffs[0]
    for i, diff in enumerate(diffs):
        if diff <= smallest:
            smallest = diff
            smallest_pos = i
    return current-prevs[smallest_pos], prevs[smallest_pos]


def calc_geomean(run_values):
    # NOTE Geometric mean applied only to positive values, so fix it by
    # adding MIN_VALUE to each value and substract it from the result.
    # Since we are only interested in the change of the central tendency,
    # this workaround is good enough.

    values = [v + MIN_VALUE_PRECISION for v in run_values]

    if not values:
        return None

    return stats.geometric_mean(values) - MIN_VALUE_PRECISION


class ComparisonResult:
    """A ComparisonResult is ultimatly responsible for determining if a test
    improves, regresses or does not change, given some new and old data."""

    def __init__(self, aggregation_fn,
                 cur_failed, prev_failed, samples, prev_samples,
                 cur_hash, prev_hash, cur_profile=None, prev_profile=None,
                 confidence_lv=0.05, bigger_is_better=False):
        self.aggregation_fn = aggregation_fn

        # Special case: if we're using the minimum to aggregate, swap it for
        # max if bigger_is_better.
        if aggregation_fn == stats.safe_min and bigger_is_better:
            aggregation_fn = stats.safe_max

        self.cur_hash = cur_hash
        self.prev_hash = prev_hash
        self.cur_profile = cur_profile
        self.prev_profile = prev_profile

        if samples:
            self.current = aggregation_fn(samples)
        else:
            self.current = None

        self.previous = None

        # Compute the comparison status for the test value.
        self.delta = 0
        self.pct_delta = 0.0
        if self.current and prev_samples:
            self.delta, value = absmin_diff(self.current, prev_samples)
            if value != 0:
                self.pct_delta = self.delta / value
            self.previous = value

        # If we have multiple values for this run, use that to estimate the
        # distribution.
        #
        # We can get integer sample types here - for example if the field is
        # .exec.status. Make sure we don't assert by avoiding the stats
        # functions in this case.
        if samples and len(samples) > 1 and isinstance(samples[0], float):
            self.stddev = stats.standard_deviation(samples)
            self.MAD = stats.median_absolute_deviation(samples)
        else:
            self.stddev = None
            self.MAD = None

        self.failed = cur_failed
        self.prev_failed = prev_failed
        self.samples = samples
        self.prev_samples = prev_samples

        self.confidence_lv = confidence_lv
        self.bigger_is_better = bigger_is_better

    def __repr__(self):
        """Print this ComparisonResult's constructor.

        Handy for generating test cases for comparisons doing odd things."""
        fmt = "{}(" + "{}, " * 9 + ")"
        return fmt.format(self.__class__.__name__,
                          self.aggregation_fn.__name__,
                          self.failed,
                          self.prev_failed,
                          self.cur_hash,
                          self.prev_hash,
                          self.samples,
                          self.prev_samples,
                          self.confidence_lv,
                          bool(self.bigger_is_better))

    def __json__(self):
        simple_dict = self.__dict__
        simple_dict['aggregation_fn'] = self.aggregation_fn.__name__
        return simple_dict

    def is_result_performance_change(self):
        """Check if we think there was a performance change."""
        if self.get_value_status() in (REGRESSED, IMPROVED):
            return True
        return False

    def is_result_interesting(self):
        """is_result_interesting() -> bool

        Check whether the result is worth displaying, either because of a
        failure, a test status change or a performance change."""
        if self.get_test_status() != UNCHANGED_PASS:
            return True
        if self.get_value_status() in (REGRESSED, IMPROVED):
            return True
        return False

    def get_test_status(self):
        # Compute the comparison status for the test success.
        if self.failed:
            if self.prev_failed:
                return UNCHANGED_FAIL
            else:
                return REGRESSED
        else:
            if self.prev_failed:
                return IMPROVED
            else:
                return UNCHANGED_PASS

    # FIXME: take into account hash of binary - if available. If the hash is
    # the same, the binary is the same and therefore the difference cannot be
    # significant - for execution time. It can be significant for compile time.
    def get_value_status(self, confidence_interval=2.576,
                         value_precision=MIN_VALUE_PRECISION,
                         ignore_small=True):
        if self.current is None or self.previous is None:
            return None

        # Don't report value errors for tests which fail, or which just started
        # passing.
        #
        # FIXME: One bug here is that we risk losing performance data on tests
        # which flop to failure then back. What would be nice to do here is to
        # find the last value in a passing run, or to move to using proper
        # keyed reference runs.
        if self.failed:
            return UNCHANGED_FAIL
        elif self.prev_failed:
            return UNCHANGED_PASS

        # Always ignore percentage changes below MIN_PERCENTAGE_CHANGE %, for now, we just don't
        # have enough time to investigate that level of stuff.
        if ignore_small and abs(self.pct_delta) < MIN_PERCENTAGE_CHANGE:
            return UNCHANGED_PASS

        # Always ignore changes with small deltas. There is no mathematical
        # basis for this, it should be obviated by appropriate statistical
        # checks, but practical evidence indicates what we currently have isn't
        # good enough (for reasons I do not yet understand).
        if ignore_small and abs(self.delta) < MIN_PERCENTAGE_CHANGE:
            return UNCHANGED_PASS

        # Ignore tests whose delta is too small relative to the precision we
        # can sample at; otherwise quantization means that we can't measure the
        # standard deviation with enough accuracy.
        if abs(self.delta) <= 2 * value_precision * confidence_interval:
            return UNCHANGED_PASS

        # Use Mann-Whitney U test to test null hypothesis that result is
        # unchanged.
        if len(self.samples) >= 4 and len(self.prev_samples) >= 4:
            same = stats.mannwhitneyu(self.samples, self.prev_samples,
                                      self.confidence_lv)
            if same:
                return UNCHANGED_PASS

        # If we have a comparison window, then measure using a symmetic
        # confidence interval.
        if self.stddev is not None:
            is_significant = abs(self.delta) > (self.stddev *
                                                confidence_interval)

            # If the delta is significant, return
            if is_significant:
                if self.delta < 0:
                    return REGRESSED if self.bigger_is_better else IMPROVED
                else:
                    return IMPROVED if self.bigger_is_better else REGRESSED
            else:
                return UNCHANGED_PASS

        # Otherwise, report any changes above 0.2%, which is a rough
        # approximation for the smallest change we expect "could" be measured
        # accurately.
        if not ignore_small or abs(self.pct_delta) >= .002:
            if self.pct_delta < 0:
                return REGRESSED if self.bigger_is_better else IMPROVED
            else:
                return IMPROVED if self.bigger_is_better else REGRESSED
        else:
            return UNCHANGED_PASS


class RunInfo(object):
    def __init__(self, session, testsuite, runs_to_load,
                 aggregation_fn=stats.safe_min, confidence_lv=.05,
                 only_tests=None):
        """Get all the samples needed to build a CR.
        runs_to_load are the run IDs of the runs to get the samples from.
        if only_tests is passed, only samples form those test IDs are fetched.
        """
        self.testsuite = testsuite
        self.aggregation_fn = aggregation_fn
        self.confidence_lv = confidence_lv

        self.sample_map = multidict.multidict()
        self.profile_map = dict()
        self.loaded_run_ids = set()

        self._load_samples_for_runs(session, runs_to_load, only_tests)

    @property
    def test_ids(self):
        return set(key[1] for key in self.sample_map.keys())

    def get_sliding_runs(self, session, run, compare_run,
                         num_comparison_runs=0):
        """
        Get num_comparison_runs most recent runs,
        This query is expensive.
        """
        runs = [run]
        runs_prev = self.testsuite \
            .get_previous_runs_on_machine(session, run, num_comparison_runs)
        runs += runs_prev

        if compare_run is not None:
            compare_runs = [compare_run]
            comp_prev = self.testsuite \
                .get_previous_runs_on_machine(session, compare_run,
                                              num_comparison_runs)
            compare_runs += comp_prev
        else:
            compare_runs = []

        return runs, compare_runs

    def get_run_comparison_result(self, run, compare_to, test_id, field,
                                  hash_of_binary_field):
        if compare_to is not None:
            compare_to = [compare_to]
        else:
            compare_to = []
        return self.get_comparison_result([run], compare_to, test_id, field,
                                          hash_of_binary_field)

    def get_samples(self, runs, test_id):
        all_samples = []
        for run in runs:
            samples = self.sample_map.get((run.id, test_id))
            if samples is not None:
                all_samples.extend(samples)
        return all_samples

    def get_comparison_result(self, runs, compare_runs, test_id, field,
                              hash_of_binary_field):
        # Get the field which indicates the requested field's status.
        status_field = field.status_field

        # Load the sample data for the current and previous runs and the
        # comparison window.
        run_samples = self.get_samples(runs, test_id)
        prev_samples = self.get_samples(compare_runs, test_id)

        cur_profile = prev_profile = None
        if runs:
            cur_profile = self.profile_map.get((runs[0].id, test_id), None)
        if compare_runs:
            prev_profile = self.profile_map.get((compare_runs[0].id, test_id),
                                                None)

        # Determine whether this (test,pset) passed or failed in the current
        # and previous runs.
        #
        # FIXME: Support XFAILs and non-determinism (mixed fail and pass)
        # better.
        run_failed = prev_failed = False
        if status_field:
            status_field_index = self.testsuite.get_field_index(status_field)
            for sample in run_samples:
                run_failed |= sample[status_field_index] == FAIL
            for sample in prev_samples:
                prev_failed |= sample[status_field_index] == FAIL

        field_index = self.testsuite.get_field_index(field)

        # Get the current and previous values.
        run_values = [s[field_index] for s in run_samples
                      if s[field_index] is not None]
        prev_values = [s[field_index] for s in prev_samples
                       if s[field_index] is not None]
        if hash_of_binary_field:
            hash_of_binary_field_index = \
                    self.testsuite.get_field_index(hash_of_binary_field)
            hash_values = [s[hash_of_binary_field_index] for s in run_samples
                           if s[hash_of_binary_field_index] is not None]
            prev_hash_values = [s[hash_of_binary_field_index]
                                for s in prev_samples
                                if s[field_index] is not None]

            # All current hash_values should all be the same.
            # Warn in the log when the hash wasn't the same for all samples.
            cur_hash_set = set(hash_values)
            if len(cur_hash_set) > 1:
                logger.warning("Found different hashes for multiple samples "
                               "in the same run {0}: {1}\nTestID:{2}"
                               .format(runs, hash_values, test_id))

            cur_hash = hash_values[0] if len(hash_values) > 0 else None
            prev_hash = prev_hash_values[0] \
                if len(prev_hash_values) > 0 else None
        else:
            cur_hash = None
            prev_hash = None
        r = ComparisonResult(self.aggregation_fn,
                             run_failed, prev_failed, run_values,
                             prev_values, cur_hash, prev_hash,
                             cur_profile, prev_profile,
                             self.confidence_lv,
                             bigger_is_better=field.bigger_is_better)
        return r

    def get_geomean_comparison_result(self, run, compare_to, field, tests):
        unchanged_tests = [(cr.previous, cr.current, cr.prev_hash, cr.cur_hash)
                           for _, _, cr in tests
                           if cr.get_test_status() == UNCHANGED_PASS]
        if unchanged_tests:
            prev_values, run_values, prev_hash, cur_hash = zip(
                *unchanged_tests)
            prev_values = [x for x in prev_values if x is not None]
            run_values = [x for x in run_values if x is not None]
            prev_hash = [x for x in prev_hash if x is not None]
            cur_hash = [x for x in cur_hash if x is not None]
            prev_hash = prev_hash[0] if len(prev_hash) > 0 else None
            cur_hash = cur_hash[0] if len(cur_hash) > 0 else None
            prev_geo = calc_geomean(prev_values)
            prev_values = [prev_geo] if prev_geo else []
            run_values = [calc_geomean(run_values)]
        else:
            prev_values, run_values, prev_hash, cur_hash = [], [], None, None

        return ComparisonResult(self.aggregation_fn,
                                cur_failed=not bool(run_values),
                                prev_failed=not bool(prev_values),
                                samples=run_values,
                                prev_samples=prev_values,
                                cur_hash=cur_hash,
                                prev_hash=prev_hash,
                                confidence_lv=0,
                                bigger_is_better=field.bigger_is_better)

    def _load_samples_for_runs(self, session, run_ids, only_tests):
        # Find the set of new runs to load.
        to_load = set(run_ids) - self.loaded_run_ids
        if not to_load:
            return

        # Batch load all of the samples for the needed runs.
        #
        # We speed things up considerably by loading the column data directly
        # here instead of requiring SA to materialize Sample objects.
        columns = [self.testsuite.Sample.run_id,
                   self.testsuite.Sample.test_id,
                   self.testsuite.Sample.profile_id]
        columns.extend(f.column for f in self.testsuite.sample_fields)
        q = session.query(*columns)
        if only_tests:
            q = q.filter(self.testsuite.Sample.test_id.in_(only_tests))
        q = q.filter(self.testsuite.Sample.run_id.in_(to_load))
        for (run_id, test_id, profile_id, *sample_values) in q:
            self.sample_map[(run_id, test_id)] = sample_values
            if profile_id is not None:
                self.profile_map[(run_id, test_id)] = profile_id

        self.loaded_run_ids |= to_load
