"""
Common functions for number sequence generation.

callable functions from this file:

    test_run
    to_original
    to_start
"""

import collections
import functools
import operator as op
import os
import sys

from mmcb import common


##############################################################################
# number sequence generation
##############################################################################

def _sequence(start, stop, step, variablestep, initial, final, bespoke_sequence_lut=None):
    """
    Generate a number sequence with constant or variable step sizes.

    Start and end points may be floating point values, all other generated
    values are integers aligned with the appropriate step size.

    --------------------------------------------------------------------------
    args
        start : numeric
            start value of the number sequence
        stop : numeric
            stop value of the number sequence
        step : int
            step size to which steps are aligned to
        variablestep : bool
            True if variable step sizes will be generated by this function,
            False otherwise
        initial : bool
            include initial value (start) in number sequence
        final : bool
            include final value (stop) in number sequence
        bespoke_sequence_lut : dict if value is supplied, or None default value
            A lookup table for determining step sizes and signs.
            This value is not used by this function, but is passed directly
            to _stepped_step() via a partial function. See _stepped_step()
            documentation for more details.
    --------------------------------------------------------------------------
    yields : series of numeric values
        first and last values may be float or int, all others will be int
    --------------------------------------------------------------------------
    """
    sign = 1 if start < stop else -1
    _step = functools.partial(_stepped_step,
                              variablestep=variablestep, step=step, sign=sign,
                              bespoke_sequence_lut=bespoke_sequence_lut)

    start_rounded = common.round_safely(start, stop, _step(start), first=True)
    stop_rounded = common.round_safely(start, stop, _step(stop), first=False)
    aligned_start = start == start_rounded
    aligned_stop = stop == stop_rounded

    # yield non-aligned initial value
    if initial and not aligned_start:
        yield start

    # yield aligned number sequence
    num = start_rounded
    if not initial and aligned_start:
        num += _step(start_rounded)
    compare_value = (stop_rounded if not final and aligned_stop
                     else stop_rounded + _step(stop_rounded))
    comparison = op.lt if sign == 1 else op.gt
    while comparison(num, compare_value):
        yield num
        num += _step(num)

    # yield non-aligned final value
    if final and not aligned_stop:
        yield stop


# wrappers for _sequence
to_start = functools.partial(_sequence, variablestep=False, initial=False, final=False)
test_run = functools.partial(_sequence, initial=True, final=True)
to_original = functools.partial(_sequence, variablestep=False, initial=False, final=True)


def _stepped_step(value, variablestep, step, sign, bespoke_sequence_lut):
    """
    Calculate the correct step size for the given value given the band it lies
    within, and the direction of travel of the number sequence.

       band  step size
     0 - 10          1
    10 - 50          5
       > 50         10

    op.lt    less than
    op.gt    greater than
    op.le    less than or equal to
    op.ge    greater than or equal to

    --------------------------------------------------------------------------
    args
        value : numeric
            either positive or negative
        variablestep : bool
            True if variable step sizes will be generated by this function,
            False otherwise
        step : int
            step size (positive value only in the context of this script)
        sign : int
            either -1 or 1 (signifies the direction of travel of the number
            sequence)
        bespoke_sequence_lut : dict
            A lookup table for determining step sizes and signs.
            See unit tests in test_sequence.py for more information.
            e.g.
            {(-1, False): [[10, op.le, 1], [50, op.le, 5], [70, op.le, 10],
                           [80, op.le, 1], [None, None, 10]],
             (-1, True): [[10, op.lt, 1], [50, op.lt, 5], [70, op.lt, 10],
                          [80, op.lt, 1], [None, None, 10]],
             (1, False): [[80, op.ge, 10], [70, op.ge, 1], [50, op.ge, 10],
                          [10, op.ge, 5], [None, None, 1]],
             (1, True): [[80, op.gt, 10], [70, op.gt, 1], [50, op.gt, 10],
                         [10, op.gt, 5], [None, None, 1]]}
    --------------------------------------------------------------------------
    returns : int
        the appropriate step size for the number range that value lies within
    --------------------------------------------------------------------------
    """
    if variablestep:
        # the order of the items in the value list is important
        test = {(-1, False): [[10, op.le,  1], [50, op.le,  5], [None, None, 10]],
                (-1,  True): [[10, op.lt,  1], [50, op.lt,  5], [None, None, 10]],
                ( 1, False): [[50, op.ge, 10], [10, op.ge,  5], [None, None,  1]],
                ( 1,  True): [[50, op.gt, 10], [10, op.gt,  5], [None, None,  1]]}
        if bespoke_sequence_lut is not None:
            test = bespoke_sequence_lut

        direction = value < 0
        value = abs(value)
        for boundary, comparison, stepsize in test[(sign, direction)]:
            if boundary is not None and comparison(value, boundary):
                break
    else:
        stepsize = abs(step)

    return stepsize * sign


##############################################################################
# allow the use of a user-defined file containing ranges and step values
##############################################################################

def _load_test_profile(filename):
    """
    Load a file containing a series of ranges and step sizes that will define
    the voltages that will be used during the IV test.

    Comments starting with '#' are supported. On any line if a '#' is found,
    it and any text to the right are discarded. Blank lines are allowed.

    Valid ranges are either (1) a single value that defines the default step
    size over the entire range from 0V to the maximum test voltage, and
    (2) three values (start, stop, step).

    If errors are encountered reading the file, continue reading the file
    anyway so the user receives all error messages at once.

    --------------------------------------------------------------------------
    args
        filename : string
            name of file containing range profile
    --------------------------------------------------------------------------
    returns
        profile : list or None if file contained invalid line(s)
            [[float, int], ...]
            e.g. a file containing:
                # default step size
                10
                # list all ranges required: start, stop, step
                0, 10, 1
                10, 50, 5
            would return:
                [[10], [0, 10, 1], [10, 50, 5]]
    --------------------------------------------------------------------------
    """
    error = False
    profile = []

    if os.path.exists(filename):
        with open(filename, 'r', encoding='utf-8') as csvfile:
            for row in csvfile:
                no_comment = row.split('#')[0].strip()

                row = [field.strip() for field in no_comment.split(',')]
                rowlen = len(row)

                if rowlen in {1, 3}:
                    if all(bool(x) for x in row):
                        profile.append([int(x) for x in row])
                else:
                    print(f'--range: malformed line: {no_comment}')
                    error = True
                    break
    else:
        message = f'--range: file {filename} could not be read from'
        print(message)

    return None if error else profile


def _check_ranges(ranges):
    """
    Basic checks on received ranges.

    --------------------------------------------------------------------------
    args
        ranges : list
            name of file containing range profile
            e.g. [[10], [0, 10, 1], [10, 50, 5]]
    --------------------------------------------------------------------------
    returns
        ranges : no explicit return, mutable type amended in place
            the line is sorted so the default step size is first in the list
                e.g. [[10], [0, 10, 1], [10, 50, 5]]
    --------------------------------------------------------------------------
    """
    counts = collections.Counter(len(x) for x in ranges)
    default_step_size_count = counts[1]

    if default_step_size_count == 0:
        print('--range: no default step size specified')
        sys.exit()
    elif default_step_size_count > 1:
        # don't do anything clever, let the user fix the problem in their file
        defaults = [x[0] for x in ranges if len(x) == 1]
        details = ', '.join(f'{x}' for x in defaults)
        print(f'--range: multiple default voltage step sizes provided: {details}')
        sys.exit()


def _clean(maxval, item):
    """
    --------------------------------------------------------------------------
    args
        maxval : int
            the maximum specified test voltage
        item : list
            e.g. [0, 10, 1]
    --------------------------------------------------------------------------
    returns
        item : no explicit return, mutable type amended in place
            the line is sorted so the default step size is first in the list
                e.g. [0, 10, 1]
    --------------------------------------------------------------------------
    """
    # make sure values are positive
    item[:] = [abs(i) for i in item]

    if len(item) == 3:
        # ensure elements are in the correct order for range(start, stop, step)
        if item[0] > item[1]:
            item[0], item[1] = item[1], item[0]

        # make sure no values will be generated beyond maxval
        if item[1] > abs(maxval):
            item[1] = abs(maxval)


def _entire_sequence(maxval, maxstep, ranges):
    """
    Generate a sequence of voltages with different step sizes spanning the
    full voltage range.

    --------------------------------------------------------------------------
    args
        maxval : int
            the maximum specified test voltage
        maxstep : int
            the maximum step size
        ranges : list
            e.g. [[10], [0, 10, 1], [10, 50, 5]]
    --------------------------------------------------------------------------
    yields : series of positive ints
        These represent the full series of voltage points spanning the range
        from 0V to the maximum test voltage.
            e.g. [  0,   1,   2,   3,   4,   5,   6,   7,   8,   9,
                   10,  15,  20,  25,  30,  35,  40,  45,  50,  60,
                   70,  80,  90, 100]
    --------------------------------------------------------------------------
    """
    # position low-length items first to make sure the default step size is
    # processed first.
    ranges.sort(key=len)

    seq = set()

    for ran in ranges:
        _clean(maxval, ran)

        try:
            start, stop, step = ran
        except ValueError:
            # Populate range with default step size.
            # Make sure we go one step beyond the end to ensure final values
            # that are not aligned to the step size will not be missed
            seq |= set(range(0, abs(maxval) + abs(maxstep), ran[0]))
            continue

        # all remove numbers inside the current range
        seq -= set(range(start, stop + 1))

        # fill set with specified range of numbers
        seq |= set(range(start, stop + 1, step))

    # low to high order
    seqc = sorted(seq)

    # reject step sequences that may damage the device under test
    diffs = [s - f for f, s in zip(seqc, seqc[1:])]
    if any(d > abs(maxstep) for d in diffs):
        print(f'--range: steps are too large {max(diffs)}')
        sys.exit()

    # deliver results
    for voltage in seqc:
        yield voltage


def _sequence_to_ranges(numseq):
    """
    Takes a number sequence and turns it into a list of ranges of equal
    consecutive step sizes that can be used to reproduce the number
    sequence.

    --------------------------------------------------------------------------
    args
        numseq : list of positive ints
            Represent the full series of voltage points spanning the range
            from 0V to the maximum test voltage. Values will always be
            positive.
            e.g. [  0,   1,   2,   3,   4,   5,   6,   7,   8,   9,
                   10,  15,  20,  25,  30,  35,  40,  45,  50,  60,
                   70,  80,  90, 100]
    --------------------------------------------------------------------------
    returns
        ranges : list
            e.g. [[0, 10, 1], [10, 50, 5], [50, 100, 10]]
    --------------------------------------------------------------------------
    """
    ranges = []

    if not numseq:
        return ranges

    last_diff = start = second = None

    for first, second in zip(numseq, numseq[1:]):
        if last_diff is None:
            last_diff = abs(second - first)
            start = first
        else:
            this_diff = abs(second - first)
            if this_diff == last_diff:
                continue

            ranges.append([start, first, last_diff])
            start = first
            last_diff = this_diff

    if second is not None:
        ranges.append([start, second, last_diff])

    return ranges


def _ranges_to_lut(ranges):
    """
    From a list of ranges that completely covers the whole number sequence,
    generate a lookup table dictionary usable by _stepped_step().

    --------------------------------------------------------------------------
    args
        ranges : list
            e.g. [[0, 10, 1], [10, 50, 5], [50, 100, 10]]
    --------------------------------------------------------------------------
    returns
        lut : dict
            e.g.
            {(-1, False): [[10, op.le,  1], [50, op.le, 5], [None, None, 10]],
             (-1,  True): [[10, op.lt,  1], [50, op.lt, 5], [None, None, 10]],
             ( 1, False): [[50, op.ge, 10], [10, op.ge, 5], [None, None,  1]],
             ( 1,  True): [[50, op.gt, 10], [10, op.gt, 5], [None, None,  1]]}
    --------------------------------------------------------------------------
    """
    first = 0
    lut = collections.defaultdict(list)

    # key (sign, direction (value < 0))
    config = {
        (-1, False): op.le,
        (-1, True): op.lt,
        (1, False): op.ge,
        (1, True): op.gt,
    }

    for sign_direction, comp in config.items():
        sign, _direction = sign_direction

        for index, (norm, reve) in enumerate(zip(ranges, reversed(ranges))):
            nstart, _nstop, nstep = norm
            _rstart, rstop, rstep = reve

            if index == first:
                if sign == -1:
                    lut[sign_direction].append([None, None, rstep])
                else:
                    lut[sign_direction].append([None, None, nstep])
            else:
                if sign == -1:
                    lut[sign_direction].append([rstop, comp, rstep])
                else:
                    lut[sign_direction].append([nstart, comp, nstep])

    # the order of the items in the value list is important
    # sign == -1: from closest to zero to furthest from zero
    # sign ==  1: from furthest from zero to closest to zero
    for key, value in lut.items():
        sign, _direction = key
        if sign == 1:
            value.sort(key=lambda x: 0 if x[0] is None else x[0], reverse=True)
        else:
            value.sort(key=lambda x: 10000 if x[0] is None else x[0])

    return dict(lut)


def read_user_range_step_file(settings, filename):
    """
    Read ranges from file, generate a lookup table suitable for
    _stepped_step.

    --------------------------------------------------------------------------
    args
        settings : dictionary
            contains core information about the test environment
        filename : string
            filename with extension
    --------------------------------------------------------------------------
    returns
        settings : dict
            no explicit return, mutable type amended in place
    --------------------------------------------------------------------------
    """
    maxval = settings['voltage']
    maxstep = 10

    # read file
    ranges = _load_test_profile(filename)
    if ranges is None:
        sys.exit()

    # make sure it's not broken
    _check_ranges(ranges)

    # generate full number sequence
    full_number_sequence = list(_entire_sequence(maxval, maxstep, ranges))

    # Now reduce the full number sequence to a list of ranges that fully define
    # the voltage range.
    all_ranges = _sequence_to_ranges(full_number_sequence)

    # now transform the all ranges list to something that can be used by
    # _stepped_step to manage its number sequences
    lut = _ranges_to_lut(all_ranges)

    settings['bespoke_sequence_lut'] = lut
