"""
Cycler
======

Cycling through combinations of values, producing dictionaries.

You can add cyclers::

    from cycler import cycler
    cc = (cycler(color=list('rgb')) +
          cycler(linestyle=['-', '--', '-.']))
    for d in cc:
        print(d)

Results in::

    {'color': 'r', 'linestyle': '-'}
    {'color': 'g', 'linestyle': '--'}
    {'color': 'b', 'linestyle': '-.'}


You can multiply cyclers::

    from cycler import cycler
    cc = (cycler(color=list('rgb')) *
          cycler(linestyle=['-', '--', '-.']))
    for d in cc:
        print(d)

Results in::

    {'color': 'r', 'linestyle': '-'}
    {'color': 'r', 'linestyle': '--'}
    {'color': 'r', 'linestyle': '-.'}
    {'color': 'g', 'linestyle': '-'}
    {'color': 'g', 'linestyle': '--'}
    {'color': 'g', 'linestyle': '-.'}
    {'color': 'b', 'linestyle': '-'}
    {'color': 'b', 'linestyle': '--'}
    {'color': 'b', 'linestyle': '-.'}
"""

from __future__ import (absolute_import, division, print_function,
                        unicode_literals)

import six
from itertools import product, cycle
from six.moves import zip, reduce
from operator import mul, add
import copy

__version__ = '0.10.0'


def _process_keys(left, right):
    """
    Helper function to compose cycler keys

    Parameters
    ----------
    left, right : iterable of dictionaries or None
        The cyclers to be composed
    Returns
    -------
    keys : set
        The keys in the composition of the two cyclers
    """
    l_peek = next(iter(left)) if left is not None else {}
    r_peek = next(iter(right)) if right is not None else {}
    l_key = set(l_peek.keys())
    r_key = set(r_peek.keys())
    if l_key & r_key:
        raise ValueError("Can not compose overlapping cycles")
    return l_key | r_key


class Cycler(object):
    """
    Composable cycles

    This class has compositions methods:

    ``+``
      for 'inner' products (zip)

    ``+=``
      in-place ``+``

    ``*``
      for outer products (itertools.product) and integer multiplication

    ``*=``
      in-place ``*``

    and supports basic slicing via ``[]``

    Parameters
    ----------
    left : Cycler or None
        The 'left' cycler

    right : Cycler or None
        The 'right' cycler

    op : func or None
        Function which composes the 'left' and 'right' cyclers.

    """
    def __call__(self):
        return cycle(self)

    def __init__(self, left, right=None, op=None):
        """Semi-private init

        Do not use this directly, use `cycler` function instead.
        """
        if isinstance(left, Cycler):
            self._left = Cycler(left._left, left._right, left._op)
        elif left is not None:
            # Need to copy the dictionary or else that will be a residual
            # mutable that could lead to strange errors
            self._left = [copy.copy(v) for v in left]
        else:
            self._left = None

        if isinstance(right, Cycler):
            self._right = Cycler(right._left, right._right, right._op)
        elif right is not None:
            # Need to copy the dictionary or else that will be a residual
            # mutable that could lead to strange errors
            self._right = [copy.copy(v) for v in right]
        else:
            self._right = None

        self._keys = _process_keys(self._left, self._right)
        self._op = op

    @property
    def keys(self):
        """
        The keys this Cycler knows about
        """
        return set(self._keys)

    def change_key(self, old, new):
        """
        Change a key in this cycler to a new name.
        Modification is performed in-place.

        Does nothing if the old key is the same as the new key.
        Raises a ValueError if the new key is already a key.
        Raises a KeyError if the old key isn't a key.

        """
        if old == new:
            return
        if new in self._keys:
            raise ValueError("Can't replace %s with %s, %s is already a key" %
                             (old, new, new))
        if old not in self._keys:
            raise KeyError("Can't replace %s with %s, %s is not a key" %
                           (old, new, old))

        self._keys.remove(old)
        self._keys.add(new)

        if self._right is not None and old in self._right.keys:
            self._right.change_key(old, new)

        # self._left should always be non-None
        # if self._keys is non-empty.
        elif isinstance(self._left, Cycler):
            self._left.change_key(old, new)
        else:
            # It should be completely safe at this point to
            # assume that the old key can be found in each
            # iteration.
            self._left = [{new: entry[old]} for entry in self._left]

    def _compose(self):
        """
        Compose the 'left' and 'right' components of this cycle
        with the proper operation (zip or product as of now)
        """
        for a, b in self._op(self._left, self._right):
            out = dict()
            out.update(a)
            out.update(b)
            yield out

    @classmethod
    def _from_iter(cls, label, itr):
        """
        Class method to create 'base' Cycler objects
        that do not have a 'right' or 'op' and for which
        the 'left' object is not another Cycler.

        Parameters
        ----------
        label : str
            The property key.

        itr : iterable
            Finite length iterable of the property values.

        Returns
        -------
        cycler : Cycler
            New 'base' `Cycler`
        """
        ret = cls(None)
        ret._left = list({label: v} for v in itr)
        ret._keys = set([label])
        return ret

    def __getitem__(self, key):
        # TODO : maybe add numpy style fancy slicing
        if isinstance(key, slice):
            trans = self.by_key()
            return reduce(add, (_cycler(k, v[key])
                                for k, v in six.iteritems(trans)))
        else:
            raise ValueError("Can only use slices with Cycler.__getitem__")

    def __iter__(self):
        if self._right is None:
            return iter(dict(l) for l in self._left)

        return self._compose()

    def __add__(self, other):
        """
        Pair-wise combine two equal length cycles (zip)

        Parameters
        ----------
        other : Cycler
           The second Cycler
        """
        if len(self) != len(other):
            raise ValueError("Can only add equal length cycles, "
                             "not {0} and {1}".format(len(self), len(other)))
        return Cycler(self, other, zip)

    def __mul__(self, other):
        """
        Outer product of two cycles (`itertools.product`) or integer
        multiplication.

        Parameters
        ----------
        other : Cycler or int
           The second Cycler or integer
        """
        if isinstance(other, Cycler):
            return Cycler(self, other, product)
        elif isinstance(other, int):
            trans = self.by_key()
            return reduce(add, (_cycler(k, v*other)
                                for k, v in six.iteritems(trans)))
        else:
            return NotImplemented

    def __rmul__(self, other):
        return self * other

    def __len__(self):
        op_dict = {zip: min, product: mul}
        if self._right is None:
            return len(self._left)
        l_len = len(self._left)
        r_len = len(self._right)
        return op_dict[self._op](l_len, r_len)

    def __iadd__(self, other):
        """
        In-place pair-wise combine two equal length cycles (zip)

        Parameters
        ----------
        other : Cycler
           The second Cycler
        """
        if not isinstance(other, Cycler):
            raise TypeError("Cannot += with a non-Cycler object")
        # True shallow copy of self is fine since this is in-place
        old_self = copy.copy(self)
        self._keys = _process_keys(old_self, other)
        self._left = old_self
        self._op = zip
        self._right = Cycler(other._left, other._right, other._op)
        return self

    def __imul__(self, other):
        """
        In-place outer product of two cycles (`itertools.product`)

        Parameters
        ----------
        other : Cycler
           The second Cycler
        """
        if not isinstance(other, Cycler):
            raise TypeError("Cannot *= with a non-Cycler object")
        # True shallow copy of self is fine since this is in-place
        old_self = copy.copy(self)
        self._keys = _process_keys(old_self, other)
        self._left = old_self
        self._op = product
        self._right = Cycler(other._left, other._right, other._op)
        return self

    def __eq__(self, other):
        """
        Check equality
        """
        if len(self) != len(other):
            return False
        if self.keys ^ other.keys:
            return False

        return all(a == b for a, b in zip(self, other))

    def __repr__(self):
        op_map = {zip: '+', product: '*'}
        if self._right is None:
            lab = self.keys.pop()
            itr = list(v[lab] for v in self)
            return "cycler({lab!r}, {itr!r})".format(lab=lab, itr=itr)
        else:
            op = op_map.get(self._op, '?')
            msg = "({left!r} {op} {right!r})"
            return msg.format(left=self._left, op=op, right=self._right)

    def _repr_html_(self):
        # an table showing the value of each key through a full cycle
        output = "<table>"
        sorted_keys = sorted(self.keys, key=repr)
        for key in sorted_keys:
            output += "<th>{key!r}</th>".format(key=key)
        for d in iter(self):
            output += "<tr>"
            for k in sorted_keys:
                output += "<td>{val!r}</td>".format(val=d[k])
            output += "</tr>"
        output += "</table>"
        return output

    def by_key(self):
        """Values by key

        This returns the transposed values of the cycler.  Iterating
        over a `Cycler` yields dicts with a single value for each key,
        this method returns a `dict` of `list` which are the values
        for the given key.

        The returned value can be used to create an equivalent `Cycler`
        using only `+`.

        Returns
        -------
        transpose : dict
            dict of lists of the values for each key.
        """

        # TODO : sort out if this is a bottle neck, if there is a better way
        # and if we care.

        keys = self.keys
        # change this to dict comprehension when drop 2.6
        out = dict((k,  list()) for k in keys)

        for d in self:
            for k in keys:
                out[k].append(d[k])
        return out

    # for back compatibility
    _transpose = by_key

    def simplify(self):
        """Simplify the Cycler

        Returned as a composition using only sums (no multiplications)

        Returns
        -------
        simple : Cycler
            An equivalent cycler using only summation"""
        # TODO: sort out if it is worth the effort to make sure this is
        # balanced.  Currently it is is
        # (((a + b) + c) + d) vs
        # ((a + b) + (c + d))
        # I would believe that there is some performance implications

        trans = self.by_key()
        return reduce(add, (_cycler(k, v) for k, v in six.iteritems(trans)))

    def concat(self, other):
        """Concatenate this cycler and an other.

        The keys must match exactly.

        This returns a single Cycler which is equivalent to
        `itertools.chain(self, other)`

        Examples
        --------

        >>> num = cycler('a', range(3))
        >>> let = cycler('a', 'abc')
        >>> num.concat(let)
        cycler('a', [0, 1, 2, 'a', 'b', 'c'])

        Parameters
        ----------
        other : `Cycler`
            The `Cycler` to concatenate to this one.

        Returns
        -------
        ret : `Cycler`
            The concatenated `Cycler`
        """
        return concat(self, other)


def concat(left, right):
    """Concatenate two cyclers.

    The keys must match exactly.

    This returns a single Cycler which is equivalent to
    `itertools.chain(left, right)`

    Examples
    --------

    >>> num = cycler('a', range(3))
    >>> let = cycler('a', 'abc')
    >>> num.concat(let)
    cycler('a', [0, 1, 2, 'a', 'b', 'c'])

    Parameters
    ----------
    left, right : `Cycler`
        The two `Cycler` instances to concatenate

    Returns
    -------
    ret : `Cycler`
        The concatenated `Cycler`
    """
    if left.keys != right.keys:
        msg = '\n\t'.join(["Keys do not match:",
                           "Intersection: {both!r}",
                           "Disjoint: {just_one!r}"]).format(
                               both=left.keys & right.keys,
                               just_one=left.keys ^ right.keys)

        raise ValueError(msg)

    _l = left.by_key()
    _r = right.by_key()
    return reduce(add, (_cycler(k, _l[k] + _r[k]) for k in left.keys))


def cycler(*args, **kwargs):
    """
    Create a new `Cycler` object from a single positional argument,
    a pair of positional arguments, or the combination of keyword arguments.

    cycler(arg)
    cycler(label1=itr1[, label2=iter2[, ...]])
    cycler(label, itr)

    Form 1 simply copies a given `Cycler` object.

    Form 2 composes a `Cycler` as an inner product of the
    pairs of keyword arguments. In other words, all of the
    iterables are cycled simultaneously, as if through zip().

    Form 3 creates a `Cycler` from a label and an iterable.
    This is useful for when the label cannot be a keyword argument
    (e.g., an integer or a name that has a space in it).

    Parameters
    ----------
    arg : Cycler
        Copy constructor for Cycler (does a shallow copy of iterables).

    label : name
        The property key. In the 2-arg form of the function,
        the label can be any hashable object. In the keyword argument
        form of the function, it must be a valid python identifier.

    itr : iterable
        Finite length iterable of the property values.
        Can be a single-property `Cycler` that would
        be like a key change, but as a shallow copy.

    Returns
    -------
    cycler : Cycler
        New `Cycler` for the given property

    """
    if args and kwargs:
        raise TypeError("cyl() can only accept positional OR keyword "
                        "arguments -- not both.")

    if len(args) == 1:
        if not isinstance(args[0], Cycler):
            raise TypeError("If only one positional argument given, it must "
                            " be a Cycler instance.")
        return Cycler(args[0])
    elif len(args) == 2:
        return _cycler(*args)
    elif len(args) > 2:
        raise TypeError("Only a single Cycler can be accepted as the lone "
                        "positional argument. Use keyword arguments instead.")

    if kwargs:
        return reduce(add, (_cycler(k, v) for k, v in six.iteritems(kwargs)))

    raise TypeError("Must have at least a positional OR keyword arguments")


def _cycler(label, itr):
    """
    Create a new `Cycler` object from a property name and
    iterable of values.

    Parameters
    ----------
    label : hashable
        The property key.

    itr : iterable
        Finite length iterable of the property values.

    Returns
    -------
    cycler : Cycler
        New `Cycler` for the given property
    """
    if isinstance(itr, Cycler):
        keys = itr.keys
        if len(keys) != 1:
            msg = "Can not create Cycler from a multi-property Cycler"
            raise ValueError(msg)

        lab = keys.pop()
        # Doesn't need to be a new list because
        # _from_iter() will be creating that new list anyway.
        itr = (v[lab] for v in itr)

    return Cycler._from_iter(label, itr)
