# -*- coding: utf-8 -*-
"""
Moler implementation of Publisher-Subscriber Design Pattern

Main characteristic:
- allow Subscribers to be garbage collected while still subscribed inside Publisher

This is required since:
- both parties may exist in different threads
- both parties may keep references to themselves creating reference cycles
"""

__author__ = 'Grzegorz Latuszek'
__copyright__ = 'Copyright (C) 2020, Nokia'
__email__ = 'grzegorz.latuszek@nokia.com'

import weakref
from threading import Lock
import six
from moler.helpers import instance_id


class Publisher(object):
    """
    Allows objects to subscribe for notification about data.
    Subscription is made by registering function to be called with this data (may be object's method).
    Function should have signature like:

    def subscriber(data):
        # handle that data
    """

    def __init__(self):
        """Create Publisher instance"""
        super(Publisher, self).__init__()
        self._subscribers = dict()
        self._subscribers_lock = Lock()

    def subscribe(self, subscriber):
        """
        Subscribe for 'data notification'

        :param observer: function to be called to notify about data.
        """
        with self._subscribers_lock:
            subscription_key, subscription_value = self._get_subscriber_key_and_value(subscriber)

            if subscription_key not in self._subscribers:
                self._subscribers[subscription_key] = subscription_value

    def unsubscribe(self, subscriber):
        """
        Unsubscribe from 'data notification'

        :param subscriber: function that was previously subscribed
        """
        with self._subscribers_lock:
            subscription_key, _ = self._get_subscriber_key_and_value(subscriber)
            if subscription_key in self._subscribers:
                del self._subscribers[subscription_key]

    def notify_subscribers(self, *args, **kwargs):
        """Notify all subscribers passing them notification parameters"""
        # need copy since calling subscribers may change self._subscribers
        current_subscribers = list(self._subscribers.values())
        for self_or_none, subscriber_function in current_subscribers:
            try:
                if self_or_none is None:
                    subscriber_function(*args, **kwargs)
                else:
                    subscriber_self = self_or_none
                    subscriber_function(subscriber_self, *args, **kwargs)
            except ReferenceError:
                pass  # ignore: weakly-referenced object no longer exists
            except Exception as exc:  # we don't want subscriber bug to kill publisher
                self.handle_subscriber_exception(self_or_none, subscriber_function, exc)

    def handle_subscriber_exception(self, subscriber_owner, subscriber_function, raised_exception):
        """
        Handle exception raised by subscriber during publishing

        :param subscriber_owner: instance of class whose method was subscribed (or None)
        :param subscriber_function: subscribed class method or raw function
        :param raised_exception: exception raised by subscriber during publishing
        :return: None
        """
        pass  # TODO: we may log it

    @staticmethod
    def _get_subscriber_key_and_value(subscriber):
        """
        Allow Subscribers to be garbage collected while still subscribed inside Publisher

        Subscribing methods of objects is tricky::

            class TheObserver(object):
                def __init__(self):
                    self.received_data = []

                def on_new_data(self, data):
                    self.received_data.append(data)

            observer1 = TheObserver()
            observer2 = TheObserver()

            subscribe(observer1.on_new_data)
            subscribe(observer2.on_new_data)
            subscribe(observer2.on_new_data)

        Even if it looks like 2 different subscriptions they all
        pass 3 different bound-method objects (different id()).
        This is so since access via observer1.on_new_data
        creates new object (bound method) on the fly.

        We want to use weakref but weakref to bound method doesn't work
        see: http://code.activestate.com/recipes/81253/
        and : https://stackoverflow.com/questions/599430/why-doesnt-the-weakref-work-on-this-bound-method
        When we wrap bound-method into weakref it may quickly disappear
        if that is only reference to bound method.
        So, we need to unbind it to have access to real method + self instance

        Unbinding above 3 examples of on_new_data will give:
        1) self                      - 2 different id()
        2) function object of class  - all 3 have same id()

        Observer key is pair: (self-id, function-id)
        """
        try:
            self_or_none = six.get_method_self(subscriber)
            self_id = instance_id(self_or_none)
            self_or_none = weakref.proxy(self_or_none)
        except AttributeError:
            self_id = 0  # default for not bound methods
            self_or_none = None

        try:
            func = six.get_method_function(subscriber)
        except AttributeError:
            func = subscriber
        function_id = instance_id(func)

        subscription_key = (self_id, function_id)
        subscription_value = (self_or_none, weakref.proxy(func))
        return subscription_key, subscription_value
