# -*- coding: utf-8 -*-
"""
Notification backend for Linux

Includes an implementation to send desktop notifications over Dbus. Responding to user
interaction with a notification requires a running asyncio event loop.
"""
from __future__ import annotations

import logging
from typing import TypeVar

from bidict import bidict
from dbus_fast.aio.message_bus import MessageBus
from dbus_fast.aio.proxy_object import ProxyInterface
from dbus_fast.errors import DBusError
from dbus_fast.signature import Variant

from ..common import Capability, Icon, Notification, Urgency
from .base import DesktopNotifierBackend

__all__ = ["DBusDesktopNotifier"]

logger = logging.getLogger(__name__)

T = TypeVar("T")

NOTIFICATION_CLOSED_EXPIRED = 1
NOTIFICATION_CLOSED_DISMISSED = 2
NOTIFICATION_CLOSED_PROGRAMMATICALLY = 3
NOTIFICATION_CLOSED_UNDEFINED = 4

DEFAULT_ACTION_KEY = "default"


class DBusDesktopNotifier(DesktopNotifierBackend):
    """DBus notification backend for Linux

    This implements the org.freedesktop.Notifications standard. The DBUS connection is
    created in a thread with a running asyncio loop to handle clicked notifications.

    :param app_name: The name of the app.
    """

    to_native_urgency = {
        Urgency.Low: Variant("y", 0),
        Urgency.Normal: Variant("y", 1),
        Urgency.Critical: Variant("y", 2),
    }

    supported_hint_signatures = {"a{sv}", "a{ss}"}

    def __init__(self, app_name: str, app_icon: Icon | None = None) -> None:
        super().__init__(app_name, app_icon)
        self.interface: ProxyInterface | None = None
        self._platform_to_interface_notification_identifier: bidict[int, str] = bidict()

    async def request_authorisation(self) -> bool:
        """
        Request authorisation to send notifications.

        :returns: Whether authorisation has been granted.
        """
        return True

    async def has_authorisation(self) -> bool:
        """
        Whether we have authorisation to send notifications.
        """
        return True

    async def _init_dbus(self) -> ProxyInterface:
        self.bus = await MessageBus().connect()
        introspection = await self.bus.introspect(
            "org.freedesktop.Notifications", "/org/freedesktop/Notifications"
        )
        self.proxy_object = self.bus.get_proxy_object(
            "org.freedesktop.Notifications",
            "/org/freedesktop/Notifications",
            introspection,
        )
        self.interface = self.proxy_object.get_interface(
            "org.freedesktop.Notifications"
        )

        # Some older interfaces may not support notification actions.
        if hasattr(self.interface, "on_notification_closed"):
            self.interface.on_notification_closed(self._on_closed)

        if hasattr(self.interface, "on_action_invoked"):
            self.interface.on_action_invoked(self._on_action)

        return self.interface

    async def _send(self, notification: Notification) -> None:
        """
        Asynchronously sends a notification via the Dbus interface.

        :param notification: Notification to send.
        """
        if not self.interface:
            self.interface = await self._init_dbus()

        actions = []

        # The "default" action is invoked when clicking on the notification body.
        if Capability.ON_CLICKED in await self.get_capabilities():
            actions += [DEFAULT_ACTION_KEY, ""]

        for button in notification.buttons:
            actions += [button.identifier, button.title]

        hints_v: dict[str, Variant] = dict()
        hints_v["urgency"] = self.to_native_urgency[notification.urgency]

        if notification.sound:
            if notification.sound.is_named():
                hints_v["sound-name"] = Variant("s", notification.sound.name)
            else:
                hints_v["sound-file"] = Variant("s", notification.sound.as_uri())

        if notification.attachment:
            hints_v["image-path"] = Variant("s", notification.attachment.as_uri())

        # The current notification spec defines hints as a Dbus dictionary type 'a{sv}',
        # represented in Python as dict[str, Variant]. However, some older notification
        # servers expect 'a{ss}' (Python dict[str, str]). We therefore check the
        # expected argument type at runtime and cast arguments accordingly.
        # See https://github.com/samschott/desktop-notifier/issues/143.
        hints_signature = get_hints_signature(self.interface)

        if hints_signature == "":
            logger.warning("Notification server not supported")
            return

        hints: dict[str, str] | dict[str, Variant]

        if hints_signature == "a{sv}":
            hints = hints_v
        elif hints_signature == "a{ss}":
            hints = {k: str(v.value) for k, v in hints_v.items()}
        else:
            hints = {}

        timeout = notification.timeout * 1000 if notification.timeout != -1 else -1

        icon: str = ""
        if notification.icon:
            icon = (
                notification.icon.as_name()
                if notification.icon.is_named()
                else notification.icon.as_uri()
            )
        elif self.app_icon:
            icon = (
                self.app_icon.as_name()
                if self.app_icon.is_named()
                else self.app_icon.as_uri()
            )

        # dbus_next proxy APIs are generated at runtime. Silence the type checker but
        # raise an AttributeError if required.
        platform_id = await self.interface.call_notify(  # type:ignore[attr-defined]
            self.app_name,
            0,
            icon,
            notification.title,
            notification.message,
            actions,
            hints,
            timeout,
        )
        self._platform_to_interface_notification_identifier[platform_id] = (
            notification.identifier
        )

    async def _clear(self, identifier: str) -> None:
        """
        Asynchronously removes a notification from the notification center
        """
        if not self.interface:
            self.interface = await self._init_dbus()

        platform_id = self._platform_to_interface_notification_identifier.inverse[
            identifier
        ]

        try:
            # dbus_next proxy APIs are generated at runtime. Silence the type checker
            # but raise an AttributeError if required.
            await self.interface.call_close_notification(  # type:ignore[attr-defined]
                platform_id
            )
        except DBusError:
            # Notification was already closed.
            # See https://specifications.freedesktop.org/notification-spec/latest/protocol.html#command-close-notification
            pass

        try:
            del self._platform_to_interface_notification_identifier.inverse[identifier]
        except KeyError:
            # Popping may have been handled already by _on_close callback.
            pass

    async def _clear_all(self) -> None:
        """
        Asynchronously clears all notifications from notification center
        """
        for identifier in list(
            self._platform_to_interface_notification_identifier.values()
        ):
            await self._clear(identifier)

    # Note that _on_action and _on_closed might be called for the same notification
    # with some notification servers. This is not a problem because the _on_action
    # call will come first, in which case we are no longer interested in calling the
    # _on_closed callback.

    def _on_action(self, nid: int, action_key: str) -> None:
        """
        Called when the user performs a notification action. This will invoke the
        handler callback.

        :param nid: The platform's notification ID as an integer.
        :param action_key: A string identifying the action to take. We choose those keys
            ourselves when scheduling the notification.
        """
        identifier = self._platform_to_interface_notification_identifier.pop(nid, "")
        notification = self._clear_notification_from_cache(identifier)

        if not notification:
            return

        if action_key == DEFAULT_ACTION_KEY:
            self.handle_clicked(identifier, notification)
            return

        self.handle_button(identifier, action_key, notification)

    def _on_closed(self, nid: int, reason: int) -> None:
        """
        Called when the user closes a notification. This will invoke the registered
        callback.

        :param nid: The platform's notification ID as an integer.
        :param reason: An integer describing the reason why the notification was closed.
        """
        identifier = self._platform_to_interface_notification_identifier.pop(nid, "")
        notification = self._clear_notification_from_cache(identifier)

        if not notification:
            return

        if reason == NOTIFICATION_CLOSED_DISMISSED:
            self.handle_dismissed(identifier, notification)

    async def _get_capabilities(self) -> frozenset[Capability]:
        if not self.interface:
            self.interface = await self._init_dbus()

        capabilities = {
            Capability.APP_NAME,
            Capability.ICON,
            Capability.TITLE,
            Capability.TIMEOUT,
            Capability.URGENCY,
            Capability.ON_DISPATCHED,
        }

        # Capabilities supported by some notification servers.
        # See https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#protocol.
        if hasattr(self.interface, "on_notification_closed"):
            capabilities.add(Capability.ON_DISMISSED)

        server_info = (
            await self.interface.call_get_server_information()  # type:ignore[attr-defined]
        )

        # xfce4-notifyd does not support a "default" action when the notification is
        # clicked. See https://docs.xfce.org/apps/xfce4-notifyd/spec.
        if server_info[0] != "Xfce Notify Daemon":
            capabilities.add(Capability.ON_CLICKED)

        cps = await self.interface.call_get_capabilities()  # type:ignore[attr-defined]

        if "actions" in cps:
            capabilities.add(Capability.BUTTONS)
        if "body" in cps:
            capabilities.add(Capability.MESSAGE)
        if "sound" in cps:
            capabilities.add(Capability.SOUND)
            capabilities.add(Capability.SOUND_NAME)

        hints_signature = get_hints_signature(self.interface)
        if hints_signature not in self.supported_hint_signatures:
            # Any hint-based capabilities are not supported because we got an unexpected
            # DBus interface.
            capabilities.discard(Capability.SOUND)
            capabilities.discard(Capability.SOUND_NAME)
            capabilities.discard(Capability.URGENCY)

        return frozenset(capabilities)


def get_hints_signature(interface: ProxyInterface) -> str:
    """Returns the dbus type signature for the hints argument"""
    methods = interface.introspection.methods
    notify_method = next(m for m in methods if m.name == "Notify")
    try:
        return str(notify_method.in_args[6].signature)
    except IndexError:
        return ""
