#!/usr/bin/env python3
#
# Copyright (c) 2026 Stormux
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA  02110-1301 USA.
#

"""Core support for speaking mako notifications via D-Bus."""

from __future__ import annotations

import html
import re
from typing import Any, Optional

from gi.repository import Gio, GLib

from . import cthulhu_state
from . import debug
from . import messages
from . import notification_presenter
from . import settings


class MakoNotificationMonitor:
    """Tracks mako's live D-Bus queue and presents new notifications."""

    BUS_NAME = "org.freedesktop.Notifications"
    OBJECT_PATH = "/fr/emersion/Mako"
    INTERFACE_NAME = "fr.emersion.Mako"
    SOURCE_NAME = "mako"

    def __init__(self, app) -> None:
        self._app = app
        self._presenter = None
        self._connection: Optional[Gio.DBusConnection] = None
        self._watch_id: int = 0
        self._properties_subscription_id: int = 0
        self._known_ids: set[int] = set()
        self._is_running: bool = False
        self._is_available: bool = False
        self._generation: int = 0
        self._tag_pattern = re.compile(r"<[^>]+>")
        self._whitespace_pattern = re.compile(r"\s+")

    def start(self) -> None:
        """Starts watching the session bus for mako."""

        if self._is_running:
            return

        try:
            self._connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
        except Exception as error:
            msg = f"MAKO MONITOR: Failed to connect to session bus: {error}"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return

        self._watch_id = Gio.bus_watch_name_on_connection(
            self._connection,
            self.BUS_NAME,
            Gio.BusNameWatcherFlags.NONE,
            self._on_name_appeared,
            self._on_name_vanished,
        )
        self._is_running = True
        debug.printMessage(debug.LEVEL_INFO, "MAKO MONITOR: Started", True)

    def stop(self, reset_live_state: bool = True) -> None:
        """Stops watching the session bus for mako."""

        if self._properties_subscription_id and self._connection is not None:
            self._connection.signal_unsubscribe(self._properties_subscription_id)
            self._properties_subscription_id = 0

        if self._watch_id:
            Gio.bus_unwatch_name(self._watch_id)
            self._watch_id = 0

        if reset_live_state:
            self._get_presenter().mark_source_unavailable(self.SOURCE_NAME)

        self._known_ids.clear()
        self._is_available = False
        self._is_running = False
        self._connection = None
        debug.printMessage(debug.LEVEL_INFO, "MAKO MONITOR: Stopped", True)

    def refresh(self, announce_new: bool = False) -> bool:
        """Refreshes mako's live queue and optionally speaks new notifications."""

        if self._connection is None or not self._is_available:
            return False

        live_notifications = self._fetch_live_notifications()
        if live_notifications is None:
            return False

        self._sync_notifications(live_notifications, announce_new)
        return True

    def dismiss_notification(self, notification_id: int) -> bool:
        """Dismisses a live mako notification by ID."""

        if self._connection is None or not self._is_available:
            return False

        try:
            options = {
                "id": GLib.Variant("u", notification_id),
                "group": GLib.Variant("b", False),
                "history": GLib.Variant("b", True),
                "all": GLib.Variant("b", False),
            }
            self._connection.call_sync(
                self.BUS_NAME,
                self.OBJECT_PATH,
                self.INTERFACE_NAME,
                "DismissNotifications",
                GLib.Variant("(a{sv})", (options,)),
                None,
                Gio.DBusCallFlags.NONE,
                -1,
                None,
            )
        except Exception as error:
            msg = f"MAKO MONITOR: Failed to dismiss notification {notification_id}: {error}"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        self.refresh(announce_new=False)
        return True

    def invoke_action(self, notification_id: int, action_key: str) -> bool:
        """Invokes a live mako notification action."""

        if self._connection is None or not self._is_available:
            return False

        try:
            self._connection.call_sync(
                self.BUS_NAME,
                self.OBJECT_PATH,
                self.INTERFACE_NAME,
                "InvokeAction",
                GLib.Variant("(us)", (notification_id, action_key)),
                None,
                Gio.DBusCallFlags.NONE,
                -1,
                None,
            )
        except Exception as error:
            msg = (
                f"MAKO MONITOR: Failed to invoke action {action_key} "
                f"for notification {notification_id}: {error}"
            )
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        self.refresh(announce_new=False)
        return True

    def is_current_entry(self, entry) -> bool:
        """Returns True if entry belongs to the current mako session."""

        return (
            self._is_available
            and entry.source == self.SOURCE_NAME
            and entry.source_generation == self._generation
        )

    def get_generation(self) -> int:
        """Returns the current mako session generation."""

        return self._generation

    def _get_presenter(self):
        presenter = self._presenter or notification_presenter.getPresenter()
        if presenter is not self._presenter:
            self._presenter = presenter

        if getattr(presenter, "_mako_monitor", None) is not self:
            presenter.set_mako_monitor(self)

        return presenter

    def _on_name_appeared(
        self,
        connection: Gio.DBusConnection,
        name: str,
        name_owner: str,
        user_data: Optional[Any] = None,
    ) -> None:
        msg = f"MAKO MONITOR: {name} appeared as {name_owner}"
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        self._connection = connection
        self._subscribe_to_properties()

        live_notifications = self._fetch_live_notifications()
        if live_notifications is None:
            self._unsubscribe_from_properties()
            self._get_presenter().mark_source_unavailable(self.SOURCE_NAME)
            self._known_ids.clear()
            self._is_available = False
            debug.printMessage(
                debug.LEVEL_INFO,
                "MAKO MONITOR: Notification daemon is not exposing the mako interface",
                True,
            )
            return

        self._generation += 1
        self._is_available = True
        self._known_ids.clear()
        self._sync_notifications(live_notifications, announce_new=False)

    def _on_name_vanished(
        self,
        connection: Gio.DBusConnection,
        name: str,
        user_data: Optional[Any] = None,
    ) -> None:
        msg = f"MAKO MONITOR: {name} vanished"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        self._unsubscribe_from_properties()
        self._get_presenter().mark_source_unavailable(self.SOURCE_NAME)
        self._known_ids.clear()
        self._is_available = False

    def _subscribe_to_properties(self) -> None:
        if self._connection is None or self._properties_subscription_id:
            return

        self._properties_subscription_id = self._connection.signal_subscribe(
            self.BUS_NAME,
            "org.freedesktop.DBus.Properties",
            "PropertiesChanged",
            self.OBJECT_PATH,
            self.INTERFACE_NAME,
            Gio.DBusSignalFlags.NONE,
            self._on_properties_changed,
        )

    def _unsubscribe_from_properties(self) -> None:
        if self._connection is None or not self._properties_subscription_id:
            return

        self._connection.signal_unsubscribe(self._properties_subscription_id)
        self._properties_subscription_id = 0

    def _on_properties_changed(
        self,
        connection: Gio.DBusConnection,
        sender_name: Optional[str],
        object_path: str,
        interface_name: str,
        signal_name: str,
        parameters: GLib.Variant,
        user_data: Optional[Any] = None,
    ) -> None:
        del connection, sender_name, object_path, interface_name, signal_name, parameters, user_data
        self.refresh(announce_new=True)

    def _fetch_live_notifications(self) -> Optional[list[dict[str, Any]]]:
        if self._connection is None:
            return None

        try:
            reply = self._connection.call_sync(
                self.BUS_NAME,
                self.OBJECT_PATH,
                self.INTERFACE_NAME,
                "ListNotifications",
                None,
                None,
                Gio.DBusCallFlags.NONE,
                -1,
                None,
            )
        except Exception as error:
            msg = f"MAKO MONITOR: Failed to list notifications: {error}"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return None

        try:
            unpacked = reply.unpack()
        except Exception as error:
            msg = f"MAKO MONITOR: Failed to unpack notification list: {error}"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return None

        if not unpacked:
            return []

        return list(unpacked[0])

    def _sync_notifications(
        self,
        live_notifications: list[dict[str, Any]],
        announce_new: bool,
    ) -> None:
        live_by_id: dict[int, dict[str, Any]] = {}
        ordered_ids: list[int] = []

        for notification in live_notifications:
            parsed = self._parse_notification(notification)
            notification_id = parsed.get("notification_id")
            if notification_id is None:
                continue
            live_by_id[notification_id] = parsed
            ordered_ids.append(notification_id)

        self._get_presenter().sync_live_notifications(
            self.SOURCE_NAME,
            live_by_id,
            self._generation,
        )

        if announce_new:
            for notification_id in ordered_ids:
                if notification_id in self._known_ids:
                    continue
                self._announce_notification(live_by_id[notification_id])

        self._known_ids = set(live_by_id)

    def _announce_notification(self, notification: dict[str, Any]) -> None:
        message = notification.get("message") or ""
        if not message:
            return

        self._get_presenter().save_notification(
            message,
            source=self.SOURCE_NAME,
            source_generation=self._generation,
            notification_id=notification.get("notification_id"),
            live=True,
            actions=notification.get("actions"),
            app_name=notification.get("app_name", ""),
            summary=notification.get("summary", ""),
            body=notification.get("body", ""),
            urgency=notification.get("urgency", -1),
            desktop_entry=notification.get("desktop_entry", ""),
        )

        script = cthulhu_state.activeScript or self._app.getScriptManager().get_default_script()
        if script is None:
            return

        voice = script.speechGenerator.voice(string=message)
        script.speakMessage(message, voice=voice)
        script.displayBrailleMessage(message, flashTime=settings.brailleFlashTime)

    def _parse_notification(self, notification: dict[str, Any]) -> dict[str, Any]:
        actions = notification.get("actions") or {}
        if not isinstance(actions, dict):
            actions = {}

        app_name = self._normalize_text(notification.get("app-name", ""))
        summary = self._normalize_text(notification.get("summary", ""))
        body = self._normalize_text(notification.get("body", ""))
        desktop_entry = self._normalize_text(notification.get("desktop-entry", ""))
        urgency = notification.get("urgency", -1)
        notification_id = notification.get("id")

        return {
            "notification_id": notification_id if isinstance(notification_id, int) else None,
            "actions": {
                str(key): self._normalize_text(value) or str(key)
                for key, value in actions.items()
            },
            "app_name": app_name,
            "summary": summary,
            "body": body,
            "urgency": urgency if isinstance(urgency, int) else -1,
            "desktop_entry": desktop_entry,
            "message": self._build_message(app_name, summary, body, desktop_entry),
        }

    def _build_message(
        self,
        app_name: str,
        summary: str,
        body: str,
        desktop_entry: str,
    ) -> str:
        parts = [messages.NOTIFICATION]
        if summary:
            parts.append(summary)
        if body and body != summary:
            parts.append(body)
        if len(parts) == 1:
            fallback = app_name or desktop_entry
            if fallback:
                parts.append(fallback)
        return " ".join(part for part in parts if part).strip()

    def _normalize_text(self, text: Any) -> str:
        if not isinstance(text, str):
            return ""

        text = html.unescape(text)
        text = self._tag_pattern.sub(" ", text)
        text = self._whitespace_pattern.sub(" ", text)
        return text.strip()
