"""
Method chaining interface.

.. versionadded:: 1.0.0
"""

import pydash as pyd

from .helpers import UNSET


__all__ = (
    "chain",
    "tap",
    "thru",
)


class Chain(object):
    """Enables chaining of :attr:`module` functions."""

    #: Object that contains attribute references to available methods.
    module = pyd

    def __init__(self, value=UNSET):
        self._value = value

    def value(self):
        """
        Return current value of the chain operations.

        Returns:
            mixed: Current value of chain operations.
        """
        return self(self._value)

    def to_string(self):
        """
        Return current value as string.

        Returns:
            str: Current value of chain operations casted to ``str``.
        """
        return self.module.to_string(self.value())

    def commit(self):
        """
        Executes the chained sequence and returns the wrapped result.

        Returns:
            Chain: New instance of :class:`Chain` with resolved value from
                previous :class:`Class`.
        """
        return Chain(self.value())

    def plant(self, value):
        """
        Return a clone of the chained sequence planting `value` as the wrapped value.

        Args:
            value (mixed): Value to plant as the initial chain value.
        """
        # pylint: disable=no-member,maybe-no-member
        wrapper = self._value
        wrappers = []

        if hasattr(wrapper, "_value"):
            wrappers = [wrapper]

            while isinstance(wrapper._value, ChainWrapper):
                wrapper = wrapper._value
                wrappers.insert(0, wrapper)

        clone = Chain(value)

        for wrap in wrappers:
            clone = ChainWrapper(clone._value, wrap.method)(*wrap.args, **wrap.kwargs)

        return clone

    @classmethod
    def get_method(cls, name):
        """
        Return valid :attr:`module` method.

        Args:
            name (str): Name of pydash method to get.

        Returns:
            function: :attr:`module` callable.

        Raises:
            InvalidMethod: Raised if `name` is not a valid :attr:`module` method.
        """
        # Python 3.5 issue with pytest doctest call where inspect module tries
        # to unwrap this class. If we don't return here, we get an
        # InvalidMethod exception.
        if name in ("__wrapped__",):  # pragma: no cover
            return cls

        method = getattr(cls.module, name, None)

        if not callable(method) and not name.endswith("_"):
            # Alias method names not ending in underscore to their underscore
            # counterpart. This allows chaining of functions like "map_()"
            # using "map()" instead.
            method = getattr(cls.module, name + "_", None)

        if not callable(method):
            raise cls.module.InvalidMethod(f"Invalid pydash method: {name}")

        return method

    def __getattr__(self, attr):
        """
        Proxy attribute access to :attr:`module`.

        Args:
            attr (str): Name of :attr:`module` function to chain.

        Returns:
            ChainWrapper: New instance of :class:`ChainWrapper` with value passed on.

        Raises:
            InvalidMethod: Raised if `attr` is not a valid function.
        """
        return ChainWrapper(self._value, self.get_method(attr))

    def __call__(self, value):
        """
        Return result of passing `value` through chained methods.

        Args:
            value (mixed): Initial value to pass through chained methods.

        Returns:
            mixed: Result of method chain evaluation of `value`.
        """
        if isinstance(self._value, ChainWrapper):
            # pylint: disable=maybe-no-member
            value = self._value.unwrap(value)
        return value


class ChainWrapper(object):
    """Wrap :class:`Chain` method call within a :class:`ChainWrapper` context."""

    def __init__(self, value, method):
        self._value = value
        self.method = method
        self.args = ()
        self.kwargs = {}

    def _generate(self):
        """Generate a copy of this instance."""
        # pylint: disable=attribute-defined-outside-init
        new = self.__class__.__new__(self.__class__)
        new.__dict__ = self.__dict__.copy()
        return new

    def unwrap(self, value=UNSET):
        """
        Execute :meth:`method` with :attr:`_value`, :attr:`args`, and :attr:`kwargs`.

        If :attr:`_value` is an instance of :class:`ChainWrapper`, then unwrap it before calling
        :attr:`method`.
        """
        # Generate a copy of ourself so that we don't modify the chain wrapper
        # _value directly. This way if we are late passing a value, we don't
        # "freeze" the chain wrapper value when a value is first passed.
        # Otherwise, we'd locked the chain wrapper value permanently and not be
        # able to reuse it.
        wrapper = self._generate()

        if isinstance(wrapper._value, ChainWrapper):
            # pylint: disable=no-member,maybe-no-member
            wrapper._value = wrapper._value.unwrap(value)
        elif not isinstance(value, ChainWrapper) and value is not UNSET:
            # Override wrapper's initial value.
            wrapper._value = value

        if wrapper._value is not UNSET:
            value = wrapper._value

        return wrapper.method(value, *wrapper.args, **wrapper.kwargs)

    def __call__(self, *args, **kwargs):
        """
        Invoke the :attr:`method` with :attr:`value` as the first argument and return a new
        :class:`Chain` object with the return value.

        Returns:
            Chain: New instance of :class:`Chain` with the results of :attr:`method` passed in as
                value.
        """
        self.args = args
        self.kwargs = kwargs
        return Chain(self)


class _Dash(object):
    """Class that provides attribute access to valid :mod:`pydash` methods and callable access to
    :mod:`pydash` method chaining."""

    def __getattr__(self, attr):
        """Proxy to :meth:`Chain.get_method`."""
        return Chain.get_method(attr)

    def __call__(self, value=UNSET):
        """Return a new instance of :class:`Chain` with `value` as the seed."""
        return Chain(value)


def chain(value=UNSET):
    """
    Creates a :class:`Chain` object which wraps the given value to enable intuitive method chaining.
    Chaining is lazy and won't compute a final value until :meth:`Chain.value` is called.

    Args:
        value (mixed): Value to initialize chain operations with.

    Returns:
        :class:`Chain`: Instance of :class:`Chain` initialized with `value`.

    Example:

        >>> chain([1, 2, 3, 4]).map(lambda x: x * 2).sum().value()
        20
        >>> chain().map(lambda x: x * 2).sum()([1, 2, 3, 4])
        20

        >>> summer = chain([1, 2, 3, 4]).sum()
        >>> new_summer = summer.plant([1, 2])
        >>> new_summer.value()
        3
        >>> summer.value()
        10

        >>> def echo(item): print(item)
        >>> summer = chain([1, 2, 3, 4]).for_each(echo).sum()
        >>> committed = summer.commit()
        1
        2
        3
        4
        >>> committed.value()
        10
        >>> summer.value()
        1
        2
        3
        4
        10

    .. versionadded:: 1.0.0

    .. versionchanged:: 2.0.0
        Made chaining lazy.

    .. versionchanged:: 3.0.0

        - Added support for late passing of `value`.
        - Added :meth:`Chain.plant` for replacing initial chain value.
        - Added :meth:`Chain.commit` for returning a new :class:`Chain` instance initialized with
          the results from calling :meth:`Chain.value`.
    """
    return Chain(value)


def tap(value, interceptor):
    """
    Invokes `interceptor` with the `value` as the first argument and then returns `value`. The
    purpose of this method is to "tap into" a method chain in order to perform operations on
    intermediate results within the chain.

    Args:
        value (mixed): Current value of chain operation.
        interceptor (callable): Function called on `value`.

    Returns:
        mixed: `value` after `interceptor` call.

    Example:

        >>> data = []
        >>> def log(value): data.append(value)
        >>> chain([1, 2, 3, 4]).map(lambda x: x * 2).tap(log).value()
        [2, 4, 6, 8]
        >>> data
        [[2, 4, 6, 8]]

    .. versionadded:: 1.0.0
    """
    interceptor(value)
    return value


def thru(value, interceptor):
    """
    Returns the result of calling `interceptor` on `value`. The purpose of this method is to pass
    `value` through a function during a method chain.

    Args:
        value (mixed): Current value of chain operation.
        interceptor (callable): Function called with `value`.

    Returns:
        mixed: Results of ``interceptor(value)``.

    Example:

        >>> chain([1, 2, 3, 4]).thru(lambda x: x * 2).value()
        [1, 2, 3, 4, 1, 2, 3, 4]

    .. versionadded:: 2.0.0
    """
    return interceptor(value)
