#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# 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.
#
# Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu

"""Module for notification messages."""

from dataclasses import dataclass, field
import time
from typing import Optional, Dict, List, Any, Callable

import gi

gi.require_version("Gtk", "3.0")
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Gdk

from . import cmdnames
from . import debug
from . import guilabels
from . import input_event
from . import keybindings
from . import messages


@dataclass
class NotificationEntry:
    """Represents a notification saved in Cthulhu's history."""

    message: str
    timestamp: float
    source: str = "generic"
    source_generation: int = 0
    notification_id: Optional[int] = None
    live: bool = False
    actions: Dict[str, str] = field(default_factory=dict)
    app_name: str = ""
    summary: str = ""
    body: str = ""
    urgency: int = -1
    desktop_entry: str = ""


class NotificationPresenter:
    """Provides access to the notification history."""

    def __init__(self) -> None:
        self._gui: Optional[Any] = None
        self._mako_monitor: Optional[Any] = None
        self._handlers: Dict[str, Callable] = self._setup_handlers()
        self._bindings: keybindings.KeyBindings = self._setup_bindings()
        self._max_size: int = 55

        # The list is arranged with the most recent message being at the end of
        # the list. The current index is relative to, and used directly, with the
        # python list, i.e. self._notifications[-3] would return the third-to-last
        # notification message.
        self._notifications: List[NotificationEntry] = []
        self._current_index: int = -1

    def get_bindings(self) -> keybindings.KeyBindings:
        """Returns the notification-presenter keybindings."""

        return self._bindings

    def get_handlers(self) -> Dict[str, Callable]:
        """Returns the notification-presenter handlers."""

        return self._handlers

    def set_mako_monitor(self, monitor: Any) -> None:
        """Associates the mako D-Bus monitor with this presenter."""

        self._mako_monitor = monitor

    def save_notification(
        self,
        message: str,
        source: str = "generic",
        source_generation: int = 0,
        notification_id: Optional[int] = None,
        live: bool = False,
        actions: Optional[Dict[str, str]] = None,
        app_name: str = "",
        summary: str = "",
        body: str = "",
        urgency: int = -1,
        desktop_entry: str = "",
    ) -> NotificationEntry:
        """Adds message to the list of notification messages."""

        tokens = ["NOTIFICATION PRESENTER: Adding '", message, "'."]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        to_remove = max(len(self._notifications) - self._max_size + 1, 0)
        self._notifications = self._notifications[to_remove:]
        entry = NotificationEntry(
            message=message,
            timestamp=time.time(),
            source=source,
            source_generation=source_generation,
            notification_id=notification_id,
            live=live,
            actions=dict(actions or {}),
            app_name=app_name,
            summary=summary,
            body=body,
            urgency=urgency,
            desktop_entry=desktop_entry,
        )
        self._notifications.append(entry)
        return entry

    def clear_list(self) -> None:
        """Clears the notifications list."""

        msg = "NOTIFICATION PRESENTER: Clearing list."
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        self._notifications = []
        self._current_index = -1

    def remove_entry(self, entry: Optional[NotificationEntry]) -> bool:
        """Removes entry from the notification history."""

        if entry is None:
            return False

        try:
            index = self._notifications.index(entry)
        except ValueError:
            return False

        del self._notifications[index]
        if not self._notifications:
            self._current_index = -1
            return True

        if self._current_index == -1:
            return True

        if index < self._current_index:
            self._current_index -= 1
        elif index == self._current_index and self._current_index >= len(self._notifications):
            self._current_index = -1

        return True

    def refresh_live_notifications(self) -> bool:
        """Refreshes live mako state without announcing new notifications."""

        if self._mako_monitor is None:
            return False

        return self._mako_monitor.refresh(announce_new=False)

    def sync_live_notifications(
        self,
        source: str,
        live_notifications: Dict[int, Dict[str, Any]],
        source_generation: int,
    ) -> None:
        """Synchronizes current live notification metadata with history."""

        live_ids = set(live_notifications)
        for entry in self._notifications:
            if entry.source != source or entry.source_generation != source_generation:
                continue

            if entry.notification_id in live_ids:
                metadata = live_notifications[entry.notification_id]
                entry.live = True
                entry.message = metadata.get("message", entry.message)
                entry.actions = dict(metadata.get("actions") or {})
                entry.app_name = metadata.get("app_name", entry.app_name)
                entry.summary = metadata.get("summary", entry.summary)
                entry.body = metadata.get("body", entry.body)
                entry.urgency = metadata.get("urgency", entry.urgency)
                entry.desktop_entry = metadata.get("desktop_entry", entry.desktop_entry)
            elif entry.live:
                entry.live = False

    def mark_source_unavailable(self, source: str) -> None:
        """Marks all live notifications from source as no longer live."""

        for entry in self._notifications:
            if entry.source == source and entry.live:
                entry.live = False

    def can_control_entry(self, entry: Optional[NotificationEntry]) -> bool:
        """Returns True if entry can still be controlled through mako."""

        if entry is None or not entry.live or entry.notification_id is None:
            return False

        if self._mako_monitor is None:
            return False

        return self._mako_monitor.is_current_entry(entry)

    def get_actions_for_entry(self, entry: Optional[NotificationEntry]) -> Dict[str, str]:
        """Returns the live action map for entry, if it is controllable."""

        if not self.can_control_entry(entry):
            return {}

        return dict(entry.actions)

    def dismiss_entry(self, script: Any, entry: Optional[NotificationEntry]) -> bool:
        """Dismisses the live notification represented by entry."""

        if not self.can_control_entry(entry) or self._mako_monitor is None:
            return False

        result = self._mako_monitor.dismiss_notification(entry.notification_id)
        if result:
            entry.live = False
            self.remove_entry(entry)
            script.presentMessage(messages.NOTIFICATION_DISMISSED)
        return result

    def invoke_action_for_entry(
        self,
        script: Any,
        entry: Optional[NotificationEntry],
        action_key: str,
    ) -> bool:
        """Invokes action_key for the live notification represented by entry."""

        if not self.can_control_entry(entry) or self._mako_monitor is None:
            return False

        result = self._mako_monitor.invoke_action(entry.notification_id, action_key)
        if result:
            script.presentMessage(messages.NOTIFICATION_ACTION_INVOKED)
        return result

    def _setup_handlers(self) -> Dict[str, Callable]:
        """Sets up and returns the notification-presenter input event handlers."""

        handlers = {}

        handlers["present_last_notification"] = input_event.InputEventHandler(
            self._present_last_notification,
            cmdnames.NOTIFICATION_MESSAGES_LAST,
        )

        handlers["present_next_notification"] = input_event.InputEventHandler(
            self._present_next_notification,
            cmdnames.NOTIFICATION_MESSAGES_NEXT,
        )

        handlers["present_previous_notification"] = input_event.InputEventHandler(
            self._present_previous_notification,
            cmdnames.NOTIFICATION_MESSAGES_PREVIOUS,
        )

        handlers["show_notification_list"] = input_event.InputEventHandler(
            self._show_notification_list,
            cmdnames.NOTIFICATION_MESSAGES_LIST,
        )

        return handlers

    def _setup_bindings(self):
        """Sets up and returns the notification-presenter key bindings."""

        bindings = keybindings.KeyBindings()

        bindings.add(
            keybindings.KeyBinding(
                "n",
                keybindings.defaultModifierMask,
                keybindings.CTHULHU_MODIFIER_MASK,
                self._handlers.get("present_last_notification"),
            )
        )

        bindings.add(
            keybindings.KeyBinding(
                "",
                keybindings.defaultModifierMask,
                keybindings.NO_MODIFIER_MASK,
                self._handlers.get("present_next_notification"),
            )
        )

        bindings.add(
            keybindings.KeyBinding(
                "",
                keybindings.defaultModifierMask,
                keybindings.NO_MODIFIER_MASK,
                self._handlers.get("present_previous_notification"),
            )
        )

        bindings.add(
            keybindings.KeyBinding(
                "n",
                keybindings.defaultModifierMask,
                keybindings.CTHULHU_CTRL_MODIFIER_MASK,
                self._handlers.get("show_notification_list"),
            )
        )

        return bindings

    def _timestamp_to_string(self, timestamp):
        diff = time.time() - timestamp
        if diff < 60:
            return messages.secondsAgo(diff)

        if diff < 3600:
            minutes = round(diff / 60)
            return messages.minutesAgo(minutes)

        if diff < 86400:
            hours = round(diff / 3600)
            return messages.hoursAgo(hours)

        days = round(diff / 86400)
        return messages.daysAgo(days)

    def _entry_to_string(self, entry: NotificationEntry) -> str:
        return f"{entry.message} {self._timestamp_to_string(entry.timestamp)}"

    def _present_last_notification(self, script, event=None):
        """Presents the last notification."""

        if not self._notifications:
            script.presentMessage(messages.NOTIFICATION_NO_MESSAGES)
            return True

        msg = "NOTIFICATION PRESENTER: Presenting last notification."
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        entry = self._notifications[-1]
        script.presentMessage(self._entry_to_string(entry))
        self._current_index = -1
        return True

    def _present_previous_notification(self, script, event=None):
        """Presents the previous notification."""

        if not self._notifications:
            script.presentMessage(messages.NOTIFICATION_NO_MESSAGES)
            return True

        msg = (
            f"NOTIFICATION PRESENTER: Presenting previous notification. "
            f"Current index: {self._current_index}"
        )
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        if self._current_index == 0:
            script.presentMessage(messages.NOTIFICATION_LIST_TOP)
            entry = self._notifications[self._current_index]
        else:
            try:
                index = self._current_index - 1
                entry = self._notifications[index]
                self._current_index -= 1
            except IndexError:
                msg = "NOTIFICATION PRESENTER: Handling IndexError exception."
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                script.presentMessage(messages.NOTIFICATION_LIST_TOP)
                entry = self._notifications[self._current_index]

        script.presentMessage(self._entry_to_string(entry))
        return True

    def _present_next_notification(self, script, event=None):
        """Presents the next notification."""

        if not self._notifications:
            script.presentMessage(messages.NOTIFICATION_NO_MESSAGES)
            return True

        msg = (
            f"NOTIFICATION PRESENTER: Presenting next notification. "
            f"Current index: {self._current_index}"
        )
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        if self._current_index == -1:
            script.presentMessage(messages.NOTIFICATION_LIST_BOTTOM)
            entry = self._notifications[self._current_index]
        else:
            try:
                index = self._current_index + 1
                entry = self._notifications[index]
                self._current_index += 1
            except IndexError:
                msg = "NOTIFICATION PRESENTER: Handling IndexError exception."
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                script.presentMessage(messages.NOTIFICATION_LIST_BOTTOM)
                entry = self._notifications[self._current_index]

        script.presentMessage(self._entry_to_string(entry))
        return True

    def _show_notification_list(self, script, event=None):
        """Opens a dialog with a list of the notifications."""

        self.refresh_live_notifications()
        if not self._notifications:
            script.presentMessage(messages.NOTIFICATION_NO_MESSAGES)
            return True

        msg = "NOTIFICATION PRESENTER: Showing notification list."
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        entries = list(reversed(self._notifications))
        title = guilabels.notifications_count(len(self._notifications))
        column_headers = [
            guilabels.NOTIFICATIONS_COLUMN_HEADER,
            guilabels.NOTIFICATIONS_RECEIVED_TIME,
        ]
        self._gui = NotificationListGUI(self, script, title, column_headers, entries)
        self._gui.show_gui()
        return True

    def on_dialog_destroyed(self):
        """Handler for the dialog being destroyed."""

        self._gui = None


class NotificationListGUI:
    """The dialog containing the notifications list."""

    RESPONSE_COPY = 1
    RESPONSE_DISMISS = 2

    def __init__(self, presenter, script, title, column_headers, entries):
        self._presenter = presenter
        self._script = script
        self._model = None
        self._tree = None
        self._selection = None
        self._dismiss_button = None
        self._actions_box = None
        self._actions_status_label = None
        self._gui = self._create_dialog(title, column_headers, entries)

    def _create_dialog(self, title, column_headers, entries):
        dialog = Gtk.Dialog(title, None, Gtk.DialogFlags.MODAL)
        dialog.set_default_size(600, 400)
        dialog.add_button(Gtk.STOCK_COPY, self.RESPONSE_COPY)
        self._dismiss_button = dialog.add_button(
            guilabels.NOTIFICATIONS_DISMISS_BUTTON,
            self.RESPONSE_DISMISS,
        )
        dialog.add_button(Gtk.STOCK_CLEAR, Gtk.ResponseType.APPLY)
        dialog.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)
        dialog.set_default_response(Gtk.ResponseType.CLOSE)

        grid = Gtk.Grid()
        grid.set_row_spacing(12)
        content_area = dialog.get_content_area()
        content_area.add(grid)

        scrolled_window = Gtk.ScrolledWindow()
        scrolled_window.set_hexpand(True)
        scrolled_window.set_vexpand(True)
        grid.add(scrolled_window)

        tree = Gtk.TreeView()
        tree.set_hexpand(True)
        tree.set_vexpand(True)
        tree_accessible = tree.get_accessible()
        if tree_accessible:
            tree_accessible.set_name(title)
        scrolled_window.add(tree)

        cols = (GObject.TYPE_STRING, GObject.TYPE_STRING, GObject.TYPE_PYOBJECT)
        for i, header in enumerate(column_headers):
            cell = Gtk.CellRendererText()
            column = Gtk.TreeViewColumn(header, cell, text=i)
            tree.append_column(column)
            if header:
                column.set_sort_column_id(i)

        self._model = Gtk.ListStore(*cols)
        for entry in entries:
            row_iter = self._model.append(None)
            self._model.set_value(row_iter, 0, entry.message)
            self._model.set_value(
                row_iter,
                1,
                self._presenter._timestamp_to_string(entry.timestamp),
            )
            self._model.set_value(row_iter, 2, entry)

        tree.set_model(self._model)
        selection = tree.get_selection()
        selection.set_mode(Gtk.SelectionMode.SINGLE)
        selection.connect("changed", self._on_selection_changed)
        if self._model.iter_n_children(None) > 0:
            selection.select_path(0)
        self._selection = selection
        self._tree = tree

        actions_frame = Gtk.Frame(label=guilabels.NOTIFICATIONS_ACTIONS_TITLE)
        actions_frame.set_label_align(0.0, 0.5)
        actions_accessible = actions_frame.get_accessible()
        if actions_accessible:
            actions_accessible.set_name(guilabels.NOTIFICATIONS_ACTIONS_TITLE)
        grid.attach_next_to(actions_frame, scrolled_window, Gtk.PositionType.BOTTOM, 1, 1)

        actions_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        actions_content.set_margin_top(6)
        actions_content.set_margin_bottom(6)
        actions_content.set_margin_start(6)
        actions_content.set_margin_end(6)
        actions_frame.add(actions_content)

        self._actions_status_label = Gtk.Label(xalign=0)
        self._actions_status_label.set_line_wrap(True)
        self._actions_status_label.set_no_show_all(True)
        actions_content.pack_start(self._actions_status_label, False, False, 0)

        self._actions_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        actions_content.pack_start(self._actions_box, False, False, 0)

        dialog.connect("response", self.on_response)
        dialog.connect("destroy", self._on_destroy)
        self._update_action_buttons()
        return dialog

    def on_response(self, dialog, response):
        """The handler for the 'response' signal."""

        if response == Gtk.ResponseType.CLOSE:
            self._gui.destroy()
            return

        if response == self.RESPONSE_COPY:
            self._copy_selected_notification()
            return

        if response == self.RESPONSE_DISMISS:
            self._dismiss_selected_notification()
            return

        if response == Gtk.ResponseType.APPLY and self._model is not None:
            self._model.clear()
            self._presenter.clear_list()
            self._script.presentMessage(messages.NOTIFICATION_NO_MESSAGES)
            time.sleep(1)
            self._gui.destroy()

    def show_gui(self):
        """Shows the notifications list dialog."""

        self._gui.show_all()
        time_stamp = Gtk.get_current_event_time()
        if not time_stamp or time_stamp > 0xFFFFFFFF:
            time_stamp = Gdk.CURRENT_TIME
        self._gui.present_with_time(int(time_stamp))

    def _on_destroy(self, widget):
        self._presenter.on_dialog_destroyed()

    def _on_selection_changed(self, selection):
        self._presenter.refresh_live_notifications()
        self._update_action_buttons()

    def _get_selected_entry(self) -> Optional[NotificationEntry]:
        if self._selection is None or self._model is None:
            return None

        model, paths = self._selection.get_selected_rows()
        if not paths and self._model.iter_n_children(None) > 0:
            self._selection.select_path(0)
            model, paths = self._selection.get_selected_rows()

        if not paths:
            return None

        iter_for_path = model.get_iter(paths[0])
        if iter_for_path is None:
            return None

        return model.get_value(iter_for_path, 2)

    def _update_action_buttons(self) -> None:
        entry = self._get_selected_entry()
        can_control = self._presenter.can_control_entry(entry)
        actions = self._presenter.get_actions_for_entry(entry)

        if self._dismiss_button is not None:
            self._dismiss_button.set_sensitive(can_control)

        self._clear_inline_action_buttons()

        if not can_control:
            self._set_inline_action_status(messages.NOTIFICATION_UNAVAILABLE)
            return

        if not actions:
            self._set_inline_action_status(messages.NOTIFICATION_NO_ACTIONS)
            return

        self._set_inline_action_status("")
        for action_key, label_text in actions.items():
            self._add_inline_action_button(entry, action_key, label_text)

    def _clear_inline_action_buttons(self) -> None:
        if self._actions_box is None:
            return

        for child in self._actions_box.get_children():
            self._actions_box.remove(child)

    def _set_inline_action_status(self, text: str) -> None:
        if self._actions_status_label is None:
            return

        self._actions_status_label.set_text(text)
        self._actions_status_label.set_visible(bool(text))

    def _add_inline_action_button(
        self,
        entry: Optional[NotificationEntry],
        action_key: str,
        label_text: str,
    ) -> None:
        if self._actions_box is None:
            return

        button = Gtk.Button(label=label_text or action_key)
        button.connect("clicked", self._on_inline_action_clicked, entry, action_key)
        self._actions_box.pack_start(button, False, False, 0)
        self._actions_box.show_all()

    def _on_inline_action_clicked(
        self,
        button: Gtk.Button,
        entry: Optional[NotificationEntry],
        action_key: str,
    ) -> None:
        del button
        self._presenter.refresh_live_notifications()
        if not self._presenter.can_control_entry(entry):
            self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE)
            self._update_action_buttons()
            return

        if not self._presenter.invoke_action_for_entry(self._script, entry, action_key):
            self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE)

        self._update_action_buttons()

    def _copy_selected_notification(self):
        entry = self._get_selected_entry()
        if entry is None:
            return

        timestamp = self._presenter._timestamp_to_string(entry.timestamp)
        if timestamp:
            text = f"{entry.message}\t{timestamp}"
        else:
            text = f"{entry.message}"

        clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
        clipboard.set_text(text, -1)
        clipboard.store()
        self._script.presentMessage(messages.CLIPBOARD_COPIED_FULL)

    def _dismiss_selected_notification(self) -> None:
        self._presenter.refresh_live_notifications()
        entry = self._get_selected_entry()
        if not self._presenter.can_control_entry(entry):
            self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE)
            self._update_action_buttons()
            return

        if not self._presenter.dismiss_entry(self._script, entry):
            self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE)
        else:
            self._remove_selected_row()

        self._update_action_buttons()

    def _remove_selected_row(self) -> None:
        if self._selection is None or self._model is None:
            return

        model, paths = self._selection.get_selected_rows()
        if not paths:
            return

        row_iter = model.get_iter(paths[0])
        if row_iter is None:
            return

        model.remove(row_iter)
        if self._model.iter_n_children(None) == 0:
            return

        row_index = self._path_to_index(paths[0])
        if row_index is None:
            self._selection.select_path(0)
            return

        next_index = min(row_index, self._model.iter_n_children(None) - 1)
        self._selection.select_path(next_index)

    def _path_to_index(self, path: Any) -> Optional[int]:
        if isinstance(path, int):
            return path

        get_indices = getattr(path, "get_indices", None)
        if callable(get_indices):
            indices = get_indices()
            if indices:
                return indices[0]

        try:
            return path[0]
        except (TypeError, IndexError, KeyError):
            return None


_presenter = None


def getPresenter():
    """Returns the Notification Presenter."""

    global _presenter
    if _presenter is None:
        _presenter = NotificationPresenter()

    if _presenter._mako_monitor is None:
        try:
            from . import cthulhu

            app = getattr(cthulhu, "cthulhuApp", None)
            if app is not None:
                monitor = getattr(app, "makoNotificationMonitor", None)
                if monitor is not None:
                    _presenter.set_mako_monitor(monitor)
        except Exception:
            pass

    return _presenter
