import functools
import weakref

from .__wrapt__ import BaseObjectProxy, _FunctionWrapperBase

# A weak function proxy. This will work on instance methods, class
# methods, static methods and regular functions. Special treatment is
# needed for the method types because the bound method is effectively a
# transient object and applying a weak reference to one will immediately
# result in it being destroyed and the weakref callback called. The weak
# reference is therefore applied to the instance the method is bound to
# and the original function. The function is then rebound at the point
# of a call via the weak function proxy.


def _weak_function_proxy_callback(ref, proxy, callback):
    if proxy._self_expired:
        return

    proxy._self_expired = True

    # This could raise an exception. We let it propagate back and let
    # the weakref.proxy() deal with it, at which point it generally
    # prints out a short error message direct to stderr and keeps going.

    if callback is not None:
        callback(proxy)


class WeakFunctionProxy(BaseObjectProxy):
    """A weak function proxy."""

    __slots__ = ("_self_expired", "_self_instance")

    def __init__(self, wrapped, callback=None):
        """Create a proxy to object which uses a weak reference. This is
        similar to the `weakref.proxy` but is designed to work with functions
        and methods. It will automatically rebind the function to the instance
        when called if the function was originally a bound method. This is
        necessary because bound methods are transient objects and applying a
        weak reference to one will immediately result in it being destroyed
        and the weakref callback called. The weak reference is therefore
        applied to the instance the method is bound to and the original
        function. The function is then rebound at the point of a call via the
        weak function proxy.
        """

        # We need to determine if the wrapped function is actually a
        # bound method. In the case of a bound method, we need to keep a
        # reference to the original unbound function and the instance.
        # This is necessary because if we hold a reference to the bound
        # function, it will be the only reference and given it is a
        # temporary object, it will almost immediately expire and
        # the weakref callback triggered. So what is done is that we
        # hold a reference to the instance and unbound function and
        # when called bind the function to the instance once again and
        # then call it. Note that we avoid using a nested function for
        # the callback here so as not to cause any odd reference cycles.

        _callback = callback and functools.partial(
            _weak_function_proxy_callback, proxy=self, callback=callback
        )

        self._self_expired = False

        if isinstance(wrapped, _FunctionWrapperBase):
            self._self_instance = weakref.ref(wrapped._self_instance, _callback)

            if wrapped._self_parent is not None:
                super(WeakFunctionProxy, self).__init__(
                    weakref.proxy(wrapped._self_parent, _callback)
                )

            else:
                super(WeakFunctionProxy, self).__init__(
                    weakref.proxy(wrapped, _callback)
                )

            return

        try:
            self._self_instance = weakref.ref(wrapped.__self__, _callback)

            super(WeakFunctionProxy, self).__init__(
                weakref.proxy(wrapped.__func__, _callback)
            )

        except AttributeError:
            self._self_instance = None

            super(WeakFunctionProxy, self).__init__(weakref.proxy(wrapped, _callback))

    def __call__(*args, **kwargs):
        def _unpack_self(self, *args):
            return self, args

        self, args = _unpack_self(*args)

        # We perform a boolean check here on the instance and wrapped
        # function as that will trigger the reference error prior to
        # calling if the reference had expired.

        instance = self._self_instance and self._self_instance()
        function = self.__wrapped__ and self.__wrapped__

        # If the wrapped function was originally a bound function, for
        # which we retained a reference to the instance and the unbound
        # function we need to rebind the function and then call it. If
        # not just called the wrapped function.

        if instance is None:
            return self.__wrapped__(*args, **kwargs)

        return function.__get__(instance, type(instance))(*args, **kwargs)
