"""
Signals are a simple pub-sub mechanism.

DOMNodes can subscribe to a signal, which will invoke a callback when the signal is published.

"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generic, TypeVar, Union
from weakref import WeakKeyDictionary, ref

import rich.repr

from textual import log

if TYPE_CHECKING:
    from textual.dom import DOMNode

SignalT = TypeVar("SignalT")

SignalCallbackType = Union[
    Callable[[SignalT], Awaitable[Any]], Callable[[SignalT], Any]
]


class SignalError(Exception):
    """Raised for Signal errors."""


@rich.repr.auto(angular=True)
class Signal(Generic[SignalT]):
    """A signal that a widget may subscribe to, in order to invoke callbacks when an associated event occurs."""

    def __init__(self, owner: DOMNode, name: str) -> None:
        """Initialize a signal.

        Args:
            owner: The owner of this signal.
            name: An identifier for debugging purposes.
        """
        self._owner = ref(owner)
        self._name = name
        self._subscriptions: WeakKeyDictionary[
            DOMNode, list[SignalCallbackType[SignalT]]
        ] = WeakKeyDictionary()

    def __rich_repr__(self) -> rich.repr.Result:
        yield "owner", self.owner
        yield "name", self._name
        yield "subscriptions", list(self._subscriptions.keys())

    @property
    def owner(self) -> DOMNode | None:
        """The owner of this Signal, or `None` if there is no owner."""
        return self._owner()

    def subscribe(
        self,
        node: DOMNode,
        callback: SignalCallbackType[SignalT],
        immediate: bool = False,
    ) -> None:
        """Subscribe a node to this signal.

        When the signal is published, the callback will be invoked.

        Args:
            node: Node to subscribe.
            callback: A callback function which takes a single argument and returns anything (return type ignored).
            immediate: Invoke the callback immediately on publish if `True`, otherwise post it to the DOM node to be
                called once existing messages have been processed.

        Raises:
            SignalError: Raised when subscribing a non-mounted widget.
        """
        if not node.is_running:
            raise SignalError(
                f"Node must be running to subscribe to a signal (has {node} been mounted)?"
            )

        if immediate:

            def signal_callback(data: SignalT) -> None:
                """Invoke the callback immediately."""
                callback(data)

        else:

            def signal_callback(data: SignalT) -> None:
                """Post the callback to the node, to call at the next opertunity."""
                node.call_next(callback, data)

        callbacks = self._subscriptions.setdefault(node, [])
        callbacks.append(signal_callback)

    def unsubscribe(self, node: DOMNode) -> None:
        """Unsubscribe a node from this signal.

        Args:
            node: Node to unsubscribe,
        """
        self._subscriptions.pop(node, None)

    def publish(self, data: SignalT) -> None:
        """Publish the signal (invoke subscribed callbacks).

        Args:
            data: An argument to pass to the callbacks.

        """
        if not self._subscriptions:
            return
        # Don't publish if the DOM is not ready or shutting down
        owner = self.owner
        if owner is None:
            return

        if not owner.is_attached or owner._pruning:
            return
        for ancestor_node in owner.ancestors_with_self:
            if not ancestor_node.is_running:
                return

        for node, callbacks in list(self._subscriptions.items()):
            if not (node.is_running and node.is_attached) or node._pruning:
                # Removed nodes that are no longer running
                self._subscriptions.pop(node)
            else:
                # Call callbacks
                for callback in callbacks:
                    try:
                        callback(data)
                    except Exception as error:
                        log.error(
                            f"error publishing signal to {node} ignored (callback={callback}); {error}"
                        )
