#-----------------------------------------------------------------------------
# Copyright (c) 2012 - 2021, Anaconda, Inc., and Bokeh Contributors.
# All rights reserved.
#
# The full license is in the file LICENSE.txt, distributed with this software.
#-----------------------------------------------------------------------------
''' Provides ``PropertyCallbackManager`` and ``EventCallbackManager``
mixin classes for adding ``on_change`` and ``on_event`` callback
interfaces to classes.
'''

#-----------------------------------------------------------------------------
# Boilerplate
#-----------------------------------------------------------------------------
from __future__ import annotations

import logging # isort:skip
log = logging.getLogger(__name__)

#-----------------------------------------------------------------------------
# Imports
#-----------------------------------------------------------------------------

# Standard library imports
from inspect import signature
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Dict,
    List,
    Sequence,
    Type,
    Union,
    cast,
)

# Bokeh imports
from ..core.types import Unknown
from ..events import Event, ModelEvent
from ..util.functions import get_param_info

if TYPE_CHECKING:
    from ..core.has_props import Setter
    from ..core.types import ID
    from ..document.document import Document
    from ..document.events import DocumentPatchedEvent

#-----------------------------------------------------------------------------
# Globals and constants
#-----------------------------------------------------------------------------

__all__ = (
    'EventCallbackManager',
    'PropertyCallbackManager',
)

#-----------------------------------------------------------------------------
# General API
#-----------------------------------------------------------------------------

# TODO (bev) the situation with no-argument Button callbacks is a mess. We
# should migrate to all callbacks receving the event as the param, even if that
# means auto-magically wrapping user-supplied callbacks for awhile.
EventCallbackWithEvent = Callable[[Event], None]
EventCallbackWithoutEvent = Callable[[], None]
EventCallback = Union[EventCallbackWithEvent, EventCallbackWithoutEvent]

PropertyCallback = Callable[[str, Unknown, Unknown], None]

class EventCallbackManager:
    ''' A mixin class to provide an interface for registering and
    triggering event callbacks on the Python side.

    '''

    document: Document | None
    id: ID
    subscribed_events: List[str]
    _event_callbacks: Dict[str, List[EventCallback]]

    def __init__(self, *args: Any, **kw: Any) -> None:
        super().__init__(*args, **kw)  # type: ignore[call-arg] # https://github.com/python/mypy/issues/5887
        self._event_callbacks = {}

    def on_event(self, event: str | Type[Event], *callbacks: EventCallback) -> None:
        ''' Run callbacks when the specified event occurs on this Model

        Not all Events are supported for all Models.
        See specific Events in :ref:`bokeh.events` for more information on
        which Models are able to trigger them.
        '''
        if not isinstance(event, str) and issubclass(event, Event):
            event = event.event_name

        for callback in callbacks:
            if _nargs(callback) != 0:
                _check_callback(callback, ('event',), what='Event callback')

        if event not in self._event_callbacks:
            self._event_callbacks[event] = [cb for cb in callbacks]
        else:
            self._event_callbacks[event].extend(callbacks)

        if event not in self.subscribed_events:
            self.subscribed_events.append(event)

    def _trigger_event(self, event: ModelEvent) -> None:
        def invoke() -> None:
            for callback in self._event_callbacks.get(event.event_name,[]):
                if event._model_id is not None and self.id == event._model_id:
                    if _nargs(callback) == 0:
                        cast(EventCallbackWithoutEvent, callback)()
                    else:
                        cast(EventCallbackWithEvent, callback)(event)

        if self.document is not None:
            from ..model import Model
            self.document.callbacks.notify_event(cast(Model, self), event, invoke)
        else:
            invoke()

    def _update_event_callbacks(self) -> None:
        if self.document is None:
            return

        for key in self._event_callbacks:
            from ..model import Model
            self.document.callbacks.subscribe(key, cast(Model, self))

class PropertyCallbackManager:
    ''' A mixin class to provide an interface for registering and
    triggering callbacks.

    '''

    document: Document | None
    _callbacks: Dict[str, List[PropertyCallback]]

    def __init__(self, *args: Any, **kw: Any) -> None:
        super().__init__(*args, **kw)  # type: ignore[call-arg] # https://github.com/python/mypy/issues/5887
        self._callbacks = {}

    def on_change(self, attr: str, *callbacks: PropertyCallback) -> None:
        ''' Add a callback on this object to trigger when ``attr`` changes.

        Args:
            attr (str) : an attribute name on this object
            callback (callable) : a callback function to register

        Returns:
            None

        '''
        if len(callbacks) == 0:
            raise ValueError("on_change takes an attribute name and one or more callbacks, got only one parameter")

        _callbacks = self._callbacks.setdefault(attr, [])
        for callback in callbacks:
            if callback in _callbacks:
                continue

            _check_callback(callback, ('attr', 'old', 'new'))
            _callbacks.append(callback)

    def remove_on_change(self, attr: str, *callbacks: PropertyCallback) -> None:
        ''' Remove a callback from this object '''
        if len(callbacks) == 0:
            raise ValueError("remove_on_change takes an attribute name and one or more callbacks, got only one parameter")
        _callbacks = self._callbacks.setdefault(attr, [])
        for callback in callbacks:
            _callbacks.remove(callback)

    def trigger(self, attr: str, old: Unknown, new: Unknown,
            hint: DocumentPatchedEvent | None = None, setter: Setter | None = None) -> None:
        ''' Trigger callbacks for ``attr`` on this object.

        Args:
            attr (str) :
            old (object) :
            new (object) :

        Returns:
            None

        '''
        def invoke() -> None:
            callbacks = self._callbacks.get(attr)
            if callbacks:
                for callback in callbacks:
                    callback(attr, old, new)
        if self.document is not None:
            from ..model import Model
            self.document.callbacks.notify_change(cast(Model, self), attr, old, new, hint, setter, invoke)
        else:
            invoke()

#-----------------------------------------------------------------------------
# Dev API
#-----------------------------------------------------------------------------

#-----------------------------------------------------------------------------
# Private API
#-----------------------------------------------------------------------------

def _nargs(fn: Callable[..., Any]) -> int:
    sig = signature(fn)
    all_names, default_values = get_param_info(sig)
    return len(all_names) - len(default_values)

def _check_callback(callback: Callable[..., Any], fargs: Sequence[str], what: str ="Callback functions") -> None:
    '''Bokeh-internal function to check callback signature'''
    sig = signature(callback)
    formatted_args = str(sig)
    error_msg = what + " must have signature func(%s), got func%s"

    all_names, default_values = get_param_info(sig)

    nargs = len(all_names) - len(default_values)
    if nargs != len(fargs):
        raise ValueError(error_msg % (", ".join(fargs), formatted_args))

#-----------------------------------------------------------------------------
# Code
#-----------------------------------------------------------------------------
