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

Unlike other platforms, sending rich "toast" notifications cannot be done via FFI /
ctypes because the C winapi only supports basic notifications with a title and message.
This backend therefore requires interaction with the Windows Runtime and uses the winrt
package with compiled components.
"""
from __future__ import annotations

import logging
import sys
import winreg
import asyncio
from collections.abc import Callable
from typing import TypeVar
from typing_extensions import ParamSpec
from xml.etree.ElementTree import Element, SubElement, tostring

from winrt.system import Object as WinRTObject
from winrt.system import unbox_string
from winrt.windows.applicationmodel.core import CoreApplication
from winrt.windows.data.xml.dom import XmlDocument
from winrt.windows.ui.notifications import (
    NotificationSetting,
    ToastActivatedEventArgs,
    ToastDismissalReason,
    ToastDismissedEventArgs,
    ToastFailedEventArgs,
    ToastNotification,
    ToastNotificationManager,
    ToastNotificationPriority,
)

# local imports
from ..common import DEFAULT_SOUND, Capability, Icon, Notification, Urgency
from .base import DesktopNotifierBackend

__all__ = ["WinRTDesktopNotifier"]

logger = logging.getLogger(__name__)

T = TypeVar("T")
P = ParamSpec("P")

DEFAULT_GROUP = "desktop-notifier-group"
DEFAULT_ACTION = "default"
REPLY_ACTION = "action=reply&amp"
BUTTON_ACTION_PREFIX = "action=button&amp;id="
REPLY_TEXTBOX_NAME = "textBox"


def register_hkey(app_id: str, app_name: str, app_icon: Icon | None = None) -> None:
    # mypy type guard
    if not sys.platform == "win32":
        return

    winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER)
    key_path = f"SOFTWARE\\Classes\\AppUserModelId\\{app_id}"
    with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, key_path) as master_key:
        winreg.SetValueEx(master_key, "DisplayName", 0, winreg.REG_SZ, app_name)
        if app_icon is not None and app_icon.is_file():
            winreg.SetValueEx(
                master_key, "IconUri", 0, winreg.REG_SZ, app_icon.as_uri()
            )


class WinRTDesktopNotifier(DesktopNotifierBackend):
    """Notification backend for the Windows Runtime

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

    _to_native_urgency = {
        Urgency.Low: ToastNotificationPriority.DEFAULT,
        Urgency.Normal: ToastNotificationPriority.DEFAULT,
        Urgency.Critical: ToastNotificationPriority.HIGH,
    }

    def __init__(self, app_name: str, app_icon: Icon | None = None) -> None:
        super().__init__(app_name, app_icon)

        manager = ToastNotificationManager.get_default()

        if not manager:
            raise RuntimeError("Could not get ToastNotificationManagerForUser")

        self.manager = manager

        # Prefer using the real App ID if detected, fall back to user-provided name
        # and icon otherwise.
        if CoreApplication.id != "":
            self.app_id = CoreApplication.id
        else:
            self.app_id = app_name
            register_hkey(app_id=app_name, app_name=app_name, app_icon=app_icon)

        notifier = self.manager.create_toast_notifier_with_id(self.app_id)

        if not notifier:
            raise RuntimeError(f"Could not get ToastNotifier for app_id: {self.app_id}")

        self.notifier = notifier

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

        :returns: Whether authorisation has been granted.
        """
        return await self.has_authorisation()

    async def has_authorisation(self) -> bool:
        """
        Whether we have authorisation to send notifications.
        """
        try:
            return bool(self.notifier.setting == NotificationSetting.ENABLED)
        except OSError:
            # See https://github.com/samschott/desktop-notifier/issues/95.
            return True

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

        :param notification: Notification to send.
        """
        toast_xml = Element("toast", {"launch": DEFAULT_ACTION})
        visual_xml = SubElement(toast_xml, "visual")
        actions_xml = SubElement(toast_xml, "actions")

        if notification.thread:
            SubElement(
                toast_xml,
                "header",
                {
                    "id": notification.thread,
                    "title": notification.thread,
                    "arguments": DEFAULT_ACTION,
                    "activationType": "background",
                },
            )

        binding = SubElement(visual_xml, "binding", {"template": "ToastGeneric"})

        title_xml = SubElement(binding, "text")
        title_xml.text = notification.title

        message_xml = SubElement(binding, "text")
        message_xml.text = notification.message

        icon_obj: Icon | None = None
        if notification.icon:
            icon_obj = notification.icon
        elif self.app_icon:
            icon_obj = self.app_icon
        if icon_obj and icon_obj.is_file():
            SubElement(
                binding,
                "image",
                {
                    "placement": "appLogoOverride",
                    "src": icon_obj.as_uri(),
                },
            )

        if notification.attachment:
            SubElement(
                binding,
                "image",
                {"placement": "hero", "src": notification.attachment.as_uri()},
            )

        if notification.reply_field:
            SubElement(
                actions_xml,
                "input",
                {"id": REPLY_TEXTBOX_NAME, "type": "text"},
            )
            reply_button_xml = SubElement(
                actions_xml,
                "action",
                {
                    "content": notification.reply_field.button_title,
                    "activationType": "background",
                    "arguments": "action=reply&amp",
                },
            )

            # If there are no other buttons, show the reply button next to the text
            # field. Otherwise, show it above other buttons.
            if not notification.buttons:
                reply_button_xml.set("hint-inputId", REPLY_TEXTBOX_NAME)

        for button in notification.buttons:
            SubElement(
                actions_xml,
                "action",
                {
                    "content": button.title,
                    "activationType": "background",
                    "arguments": BUTTON_ACTION_PREFIX + button.identifier,
                },
            )

        if notification.sound:
            if notification.sound == DEFAULT_SOUND:
                sound_attr = {"src": "ms-winsoundevent:Notification.Default"}
            elif notification.sound.name:
                sound_attr = {"src": notification.sound.name}
            else:
                sound_attr = {"src": notification.sound.as_uri()}
        else:
            sound_attr = {"silent": "true"}

        SubElement(toast_xml, "audio", sound_attr)

        xml_document = XmlDocument()
        xml_document.load_xml(tostring(toast_xml, encoding="unicode"))

        native = ToastNotification(xml_document)
        native.tag = notification.identifier
        native.group = DEFAULT_GROUP
        native.priority = self._to_native_urgency[notification.urgency]

        native.add_activated(eventloop_wrapper(self._on_activated))
        native.add_dismissed(eventloop_wrapper(self._on_dismissed))
        native.add_failed(eventloop_wrapper(self._on_failed))

        self.notifier.show(native)

    def _on_activated(
        self, sender: ToastNotification | None, boxed_activated_args: WinRTObject | None
    ) -> None:
        if not sender:
            return

        notification = self._clear_notification_from_cache(sender.tag)

        if not boxed_activated_args:
            return

        activated_args = boxed_activated_args.as_(ToastActivatedEventArgs)
        action_id = activated_args.arguments

        if action_id == DEFAULT_ACTION:
            self.handle_clicked(sender.tag, notification)

        elif action_id == REPLY_ACTION and activated_args.user_input:
            boxed_reply = activated_args.user_input[REPLY_TEXTBOX_NAME]
            reply = unbox_string(boxed_reply)
            self.handle_replied(sender.tag, reply, notification)

        elif action_id.startswith(BUTTON_ACTION_PREFIX):
            button_id = action_id.replace(BUTTON_ACTION_PREFIX, "")
            self.handle_button(sender.tag, button_id, notification)

    def _on_dismissed(
        self,
        sender: ToastNotification | None,
        dismissed_args: ToastDismissedEventArgs | None,
    ) -> None:
        if not sender:
            return

        notification = self._clear_notification_from_cache(sender.tag)

        if (
            dismissed_args
            and dismissed_args.reason == ToastDismissalReason.USER_CANCELED
        ):
            self.handle_dismissed(sender.tag, notification)

    def _on_failed(
        self, sender: ToastNotification | None, failed_args: ToastFailedEventArgs | None
    ) -> None:
        if not sender:
            return

        self._clear_notification_from_cache(sender.tag)
        if failed_args:
            logger.warning(
                "Notification '%s' failed with error code %s",
                sender.tag,
                failed_args.error_code.value,
            )
        else:
            logger.warning("Notification '%s' failed with unknown error", sender.tag)

    async def _clear(self, identifier: str) -> None:
        """
        Asynchronously removes a notification from the notification center.
        """
        if self.manager.history:
            self.manager.history.remove_grouped_tag_with_id(
                identifier, DEFAULT_GROUP, self.app_id
            )

    async def _clear_all(self) -> None:
        """
        Asynchronously clears all notifications from notification center.
        """
        if self.manager.history:
            self.manager.history.clear_with_id(self.app_id)

    async def get_current_notifications(self) -> list[str]:
        if self.manager.history:
            notifications = self.manager.history.get_history_with_id(self.app_id)
            return [n.tag for n in notifications]
        return await super().get_current_notifications()

    async def _get_capabilities(self) -> frozenset[Capability]:
        capabilities = {
            Capability.TITLE,
            Capability.MESSAGE,
            Capability.ICON,
            Capability.BUTTONS,
            Capability.REPLY_FIELD,
            Capability.ON_DISPATCHED,
            Capability.ON_CLICKED,
            Capability.ON_DISMISSED,
            Capability.THREAD,
            Capability.ATTACHMENT,
            Capability.SOUND,
            Capability.SOUND_NAME,
        }
        # Custom audio is support only starting with the Windows 10 Anniversary update.
        # See https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/custom-audio-on-toasts#add-the-custom-audio.
        if sys.getwindowsversion().build >= 1607:  # type:ignore[attr-defined]
            capabilities.add(Capability.SOUND_FILE)

        return frozenset(capabilities)


def eventloop_wrapper(function: Callable[P, T]) -> Callable[P, None]:
    event_loop = asyncio.get_running_loop()

    def wrapped_func(*args: P.args, **kwargs: P.kwargs) -> None:
        event_loop.call_soon_threadsafe(function, *args)

    return wrapped_func
