# Orca
#
# Copyright 2011-2024 Igalia, S.L.
# Author: Joanmarie Diggs <jdiggs@igalia.com>
#
# 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.

# pylint: disable=too-many-instance-attributes
# pylint: disable=too-many-locals
# pylint: disable=too-many-lines

"""Manager for accessible object events."""

from __future__ import annotations

import enum
import itertools
import queue
import threading
import time
from typing import TYPE_CHECKING, Any

import gi

gi.require_version("Atspi", "2.0")
from gi.repository import Atspi, GLib

from . import (
    braille_presenter,
    dbus_service,
    debug,
    focus_manager,
    input_event,
    input_event_manager,
    orca_modifier_manager,
    script_manager,
    systemd,
)
from .ax_object import AXObject
from .ax_utilities import AXUtilities
from .ax_utilities_debugging import AXUtilitiesDebugging

if TYPE_CHECKING:
    from .scripts import default


class EventPriority(enum.IntEnum):
    """Priority levels for accessible object events."""

    IMMEDIATE = enum.auto()
    IMPORTANT = enum.auto()
    HIGH = enum.auto()
    MEDIUM_HIGH = enum.auto()
    NORMAL = enum.auto()
    LOWER = enum.auto()
    LOW = enum.auto()


class EventManager:
    """Manager for accessible object events."""

    _SKIPPABLE_SAME_TYPE_PREFIXES = (
        "document:page-changed",
        "object:active-descendant-changed",
        "object:children-changed",
        "object:property-change",
        "object:selection-changed",
        "object:state-changed",
        "object:text-caret-moved",
        "object:text-selection-changed",
        "window",
    )

    _SKIPPABLE_SIBLING_PREFIXES = ("object:state-changed:focused",)

    _SKIPPABLE_WINDOW_PREFIXES = (
        "window:activate",
        "window:deactivate",
    )

    def __init__(self) -> None:
        debug.print_message(debug.LEVEL_INFO, "EVENT MANAGER: Initializing", True)
        self._script_listener_counts: dict[str, int] = {}
        self._active: bool = False
        self._paused: bool = False
        self._counter = itertools.count()
        self._event_queue: queue.PriorityQueue[tuple[EventPriority, int, Atspi.Event]] = (
            queue.PriorityQueue(0)
        )
        self._gidle_id: int = 0
        self._gidle_lock = threading.Lock()
        self._listener: Atspi.EventListener = Atspi.EventListener.new(self._enqueue_object_event)
        self._event_history: dict[str, tuple[int | None, float]] = {}
        self._latest_event: dict[tuple[str, int], int] = {}
        dbus_service.get_remote_controller().register_decorated_module("EventManager", self)
        debug.print_message(debug.LEVEL_INFO, "Event manager initialized", True)

    def is_idle(self) -> bool:
        """Returns True if the object-event queue is empty and nothing is queued to process."""

        with self._gidle_lock:
            return self._event_queue.empty() and self._gidle_id == 0

    @dbus_service.testing_command
    def is_idle_for_testing(
        self,
        token: str = "",  # pylint: disable=unused-argument
        script: default.Script | None = None,  # pylint: disable=unused-argument
        event: input_event.InputEvent | None = None,  # pylint: disable=unused-argument
    ) -> bool:
        """Returns True if Orca has finished processing queued events (test-only)."""

        return self.is_idle()

    def activate(self) -> None:
        """Called when this event manager is activated."""

        debug.print_message(debug.LEVEL_INFO, "EVENT MANAGER: Activating", True, True)
        if self._active:
            debug.print_message(debug.LEVEL_INFO, "EVENT MANAGER: Already activated", True)
            return

        input_event_manager.get_manager().start_key_watcher()
        orca_modifier_manager.get_manager().add_grabs_for_orca_modifiers()
        self._active = True
        debug.print_message(debug.LEVEL_INFO, "EVENT MANAGER: Activated", True)

    def deactivate(self) -> None:
        """Called when this event manager is deactivated."""

        debug.print_message(debug.LEVEL_INFO, "EVENT MANAGER: Deactivating", True, True)
        if not self._active:
            debug.print_message(debug.LEVEL_INFO, "EVENT MANAGER: Already deactivated", True)
            return

        input_event_manager.get_manager().stop_key_watcher()
        self._active = False
        with self._gidle_lock:
            self._event_queue = queue.PriorityQueue(0)
            self._latest_event = {}
        self._script_listener_counts = {}
        debug.print_message(debug.LEVEL_INFO, "EVENT MANAGER: Deactivated", True)

    def pause_queuing(
        self,
        pause: bool = True,
        clear_queue: bool = False,
        reason: str = "",
    ) -> None:
        """Pauses/unpauses event queuing."""

        tokens = [
            "EVENT MANAGER: Pause queueing:",
            pause,
            ". Clear queue:",
            clear_queue,
            ".",
            reason,
        ]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        self._paused = pause
        if clear_queue:
            with self._gidle_lock:
                self._event_queue = queue.PriorityQueue(0)
                self._latest_event = {}
        input_event_manager.get_manager().pause_key_watcher(pause, reason)

    def _get_text_changed_priority(self, event: Atspi.Event) -> EventPriority:
        """Returns the priority of a text-changed event, which is higher for live regions."""

        if not event.type.startswith("object:text-changed:insert"):
            return EventPriority.MEDIUM_HIGH

        if not AXUtilities.has_live_region_role(event.source):
            return EventPriority.MEDIUM_HIGH

        live = AXObject.get_attribute(event.source, "container-live")
        if live == "assertive":
            return EventPriority.IMPORTANT
        if live == "polite":
            return EventPriority.HIGH
        return EventPriority.MEDIUM_HIGH

    def _get_priority(self, event: Atspi.Event) -> EventPriority:
        """Returns the priority associated with event."""

        event_type = event.type
        if event_type.startswith("window") or (
            event_type == "object:state-changed:active"
            and (AXUtilities.is_frame(event.source) or AXUtilities.is_dialog_or_alert(event.source))
        ):
            priority = EventPriority.IMPORTANT
        elif event_type.startswith(
            ("object:state-changed:focused", "object:active-descendant-changed"),
        ):
            priority = EventPriority.HIGH
        elif event_type.startswith("object:announcement"):
            if event.detail1 == Atspi.Live.ASSERTIVE:
                priority = EventPriority.IMPORTANT
            elif event.detail1 == Atspi.Live.POLITE:
                priority = EventPriority.HIGH
            else:
                priority = EventPriority.NORMAL
        elif event_type.startswith("object:state-changed:invalid-entry"):
            # Setting this to lower ensures we present the state and/or text changes that triggered
            # the invalid state prior to presenting the invalid state.
            priority = EventPriority.LOWER
        elif event_type.startswith("object:children-changed"):
            priority = EventPriority.LOW
        elif event_type.startswith("object:text-changed"):
            priority = self._get_text_changed_priority(event)
        elif event_type.startswith("object:property-change:accessible-description"):
            priority = EventPriority.LOWER
        else:
            priority = EventPriority.NORMAL

        tokens = ["EVENT MANAGER:", event, "has priority level:", priority.name]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        return priority

    # pylint: disable-next=too-many-return-statements
    def _is_obsoleted_by(self, event: Atspi.Event, counter: int = -1) -> Atspi.Event | None:
        """Returns the event which renders this one no longer worthy of being processed."""

        if event.type.startswith(EventManager._SKIPPABLE_SAME_TYPE_PREFIXES):
            key = (event.type, hash(event.source))
            latest = self._latest_event.get(key, -1)
            if latest > counter >= 0:
                tokens = [
                    "EVENT MANAGER:",
                    event,
                    "(#",
                    counter,
                    ") obsoleted: a newer",
                    event.type,
                    "for same source is queued (#",
                    latest,
                    ")",
                ]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return event

        event_is_skippable_sibling = event.type.startswith(EventManager._SKIPPABLE_SIBLING_PREFIXES)
        event_is_window = event.type.startswith(EventManager._SKIPPABLE_WINDOW_PREFIXES)

        if not event_is_skippable_sibling and not event_is_window:
            return None

        def is_same(x):
            return (
                x.type == event.type
                and x.source == event.source
                and x.detail1 == event.detail1
                and x.detail2 == event.detail2
                and x.any_data == event.any_data
            )

        def obsoletes_if_same_type_in_sibling(x):
            if not event_is_skippable_sibling:
                return False
            if (
                x.type != event.type
                or x.detail1 != event.detail1
                or x.detail2 != event.detail2
                or x.any_data != event.any_data
            ):
                return False
            return AXObject.get_parent(x.source) == AXObject.get_parent(event.source)

        def obsoletes_window_event(x):
            if not event_is_window:
                return False
            if x.source != event.source:
                return False
            return x.type.startswith(EventManager._SKIPPABLE_WINDOW_PREFIXES)

        with self._event_queue.mutex:
            try:
                events = list(reversed(self._event_queue.queue))
            except queue.Empty as error:
                tokens = ["EVENT MANAGER: Exception in _is_obsoleted_by:", error]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                events = []

        for _priority, _counter, e in events:
            if e == event:
                return None
            if is_same(e):
                tokens = ["EVENT MANAGER:", event, "obsoleted by", e, "more recent duplicate"]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return e
            if obsoletes_if_same_type_in_sibling(e):
                tokens = [
                    "EVENT MANAGER:",
                    event,
                    "obsoleted by",
                    e,
                    "more recent event of same type from sibling",
                ]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return e
            if obsoletes_window_event(e):
                tokens = [
                    "EVENT MANAGER:",
                    event,
                    "obsoleted by",
                    e,
                    "more recent window (de)activation event",
                ]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return e

        return None

    def _ignore_by_role(self, event: Atspi.Event, role: Atspi.Role) -> bool | None:
        """Returns True/False if the source role determines ignore, or None if inconclusive."""

        event_type = event.type

        # gnome-shell fires "focused" events spuriously after the Alt+Tab switcher
        # is used and something else has claimed focus.
        if AXUtilities.is_window(event.source, role) and "focused" in event_type:
            tokens = ["EVENT MANAGER: Ignoring", event_type, "based on type and role"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return True

        if AXUtilities.is_frame(event.source, role):
            app = AXUtilities.get_application(event.source)
            ignore = AXUtilities.is_mutter_x11_frames(app)
            prefix = "Ignoring" if ignore else "Not ignoring"
            reason = "application" if ignore else "role"
            tokens = ["EVENT MANAGER:", prefix, event_type, "based on", reason]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return ignore

        # Events from the text role are typically something we want to handle.
        # One exception is a huge text insertion.
        if AXUtilities.is_text(event.source, role):
            if event_type.startswith("object:text-changed:insert") and event.detail2 > 5000:
                tokens = ["EVENT_MANAGER: Ignoring", event_type, "due to size of inserted text"]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return True
            if not event_type.startswith("object:text-caret-moved"):
                tokens = ["EVENT_MANAGER: Not ignoring", event_type, "due to role"]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return False

        if AXUtilities.is_notification(event.source, role) or AXUtilities.is_alert(
            event.source, role
        ):
            tokens = ["EVENT_MANAGER: Not ignoring", event_type, "due to role"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return False

        return None

    def _ignore_by_focus_state(
        self,
        event: Atspi.Event,
        focus: Atspi.Accessible | None,
        role: Atspi.Role,
    ) -> bool | None:
        """Returns False if focus/state means we should not ignore, or None if inconclusive."""

        event_type = event.type
        if focus in (event.source, event.any_data):
            reason = "source" if focus == event.source else "any_data"
            tokens = [
                "EVENT_MANAGER: Not ignoring",
                event_type,
                "due to",
                reason,
                "being locus of focus",
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return False

        source_states = AXObject.get_state_set(event.source)
        if AXUtilities.is_selected(event.source, source_states):
            tokens = ["EVENT_MANAGER: Not ignoring", event_type, "due to source being selected"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return False

        # We see an unbelievable number of active-descendant-changed and selection changed
        # from Caja when the user navigates from one giant folder to another. We need the
        # spam filtering below to catch this bad behavior coming from a focused object, so
        # only return early here if the focused object doesn't manage descendants, or the
        # event is not a focus claim.
        if AXUtilities.is_focused(event.source, source_states):
            if not AXUtilities.manages_descendants(event.source, source_states) or (
                event_type.startswith("object:state-changed:focused") and event.detail1
            ):
                tokens = ["EVENT_MANAGER: Not ignoring", event_type, "due to source being focused"]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return False

        if event_type.startswith("object:text-changed:insert") and AXUtilities.has_live_region_role(
            event.source, role
        ):
            live = AXObject.get_attribute(event.source, "live")
            if live and live != "off":
                tokens = [
                    "EVENT_MANAGER: Not ignoring",
                    event_type,
                    "due to source being live region",
                ]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return False

        return None

    def _ignore_by_spam_filter(self, event: Atspi.Event) -> bool | None:
        """Returns True if the event is spam, or None if inconclusive."""

        event_type = event.type
        last_app, last_time = self._event_history.get(event_type, (None, 0))
        app = AXUtilities.get_application(event.source)
        ignore = last_app == hash(app) and time.time() - last_time < 0.1
        self._event_history[event_type] = hash(app), time.time()
        if ignore:
            tokens = [
                "EVENT_MANAGER: Ignoring",
                event_type,
                "due to multiple instances in short time",
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return True

        if AXUtilities.is_mutter_x11_frames(app):
            tokens = ["EVENT MANAGER: Ignoring", event_type, "based on application"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return True

        return None

    def _ignore_children_changed(
        self,
        event: Atspi.Event,
        focus: Atspi.Accessible | None,
    ) -> bool | None:
        """Returns True/False for children-changed events, or None if not applicable."""

        event_type = event.type
        if not event_type.startswith("object:children-changed"):
            return None

        if "remove" in event_type:
            if (focus and AXObject.is_dead(focus)) or event.source == AXUtilities.get_desktop():
                return False

        child = event.any_data
        if child is None or AXObject.is_dead(child):
            tokens = ["EVENT_MANAGER: Ignoring", event_type, "due to null/dead event.any_data"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return True
        if AXUtilities.is_menu_related(child) or AXUtilities.is_image(child):
            tokens = ["EVENT_MANAGER: Ignoring", event_type, "due to role of event.any_data"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return True

        script = script_manager.get_manager().get_active_script()
        if script is None or script.app != AXUtilities.get_application(event.source):
            reason = (
                "there is no active script" if script is None else "event is not from active app"
            )
            tokens = ["EVENT MANAGER: Ignoring", event_type, "because", reason]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return True

        return None

    @staticmethod
    def _ignore_property_change(event: Atspi.Event, role: Atspi.Role) -> bool | None:
        """Returns True/False for property-change events, or None if not applicable."""

        event_type = event.type
        if not event_type.startswith("object:property-change"):
            return None

        if "name" in event_type:
            ignore_name_roles = [
                Atspi.Role.CANVAS,
                Atspi.Role.CHECK_BOX,
                Atspi.Role.ICON,
                Atspi.Role.IMAGE,
                Atspi.Role.LIST,
                Atspi.Role.LIST_ITEM,
                Atspi.Role.MENU,
                Atspi.Role.MENU_ITEM,
                Atspi.Role.PANEL,
                Atspi.Role.RADIO_BUTTON,
                Atspi.Role.SECTION,
                Atspi.Role.TABLE_ROW,
                Atspi.Role.TABLE_CELL,
                Atspi.Role.TREE_ITEM,
            ]
            if role in ignore_name_roles:
                tokens = ["EVENT MANAGER: Ignoring", event_type, "due to role of unfocused source"]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return True
            return False
        if "value" in event_type:
            if role in [Atspi.Role.SPLIT_PANE, Atspi.Role.SCROLL_BAR]:
                tokens = ["EVENT MANAGER: Ignoring", event_type, "due to role of unfocused source"]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return True
            return False

        return None

    @staticmethod
    def _ignore_state_changed(event: Atspi.Event, role: Atspi.Role) -> bool | None:
        """Returns True/False for state-changed events, or None if not applicable."""

        event_type = event.type
        if not event_type.startswith("object:state-changed"):
            return None

        if event_type.endswith("system"):
            system_ignore_roles = [
                Atspi.Role.TABLE,
                Atspi.Role.TABLE_CELL,
                Atspi.Role.TABLE_ROW,
                Atspi.Role.TREE,
                Atspi.Role.TREE_ITEM,
                Atspi.Role.TREE_TABLE,
            ]
            if role in system_ignore_roles:
                tokens = ["EVENT MANAGER: Ignoring", event_type, "based on role"]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return True

        return EventManager._ignore_state_changed_subtype(event, event_type, role)

    @staticmethod
    def _ignore_state_changed_subtype(
        event: Atspi.Event,
        event_type: str,
        role: int,
    ) -> bool | None:
        """Returns True/False for state-changed subtypes, or None if not applicable."""

        showing_roles = [
            Atspi.Role.ALERT,
            Atspi.Role.ANIMATION,
            Atspi.Role.DIALOG,
            Atspi.Role.INFO_BAR,
            Atspi.Role.MENU,
            Atspi.Role.NOTIFICATION,
            Atspi.Role.STATUS_BAR,
            Atspi.Role.TOOL_TIP,
        ]

        ignore: bool | None = None
        reason = ""
        if "checked" in event_type:
            ignore = not AXUtilities.is_showing(event.source)
            reason = "unfocused, non-showing source"
        elif "selected" in event_type:
            ignore = not event.detail1 and role == Atspi.Role.BUTTON
            reason = "role of source and detail1"
        elif "sensitive" in event_type:
            ignore = role not in [Atspi.Role.TEXT, Atspi.Role.ENTRY]
            reason = "role of unfocused source"
        elif "showing" in event_type:
            ignore = role not in showing_roles
            reason = "role"

        if ignore is None:
            return None

        if ignore:
            tokens = ["EVENT MANAGER: Ignoring", event_type, "due to", reason]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        return ignore

    @staticmethod
    def _ignore_active_descendant_or_selection(event: Atspi.Event) -> bool | None:
        """Returns True/False for active-descendant and selection events."""

        event_type = event.type
        if event_type.startswith("object:active-descendant-changed"):
            child = event.any_data
            if child is None or AXUtilities.is_invalid_role(child):
                tokens = [
                    "EVENT_MANAGER: Ignoring",
                    event_type,
                    "due to null/invalid event.any_data",
                ]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return True
            return False

        if event_type.startswith("object:selection-changed"):
            if AXObject.is_dead(event.source, AXUtilities.get_application(event.source)):
                tokens = ["EVENT MANAGER: Ignoring", event_type, "from dead source"]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return True
            return False

        return None

    @staticmethod
    def _ignore_text_events(
        event: Atspi.Event,
        focus: Atspi.Accessible | None,
        role: Atspi.Role,
    ) -> bool | None:
        """Returns True/False for text caret-moved and text-changed events."""

        event_type = event.type
        if event_type.startswith("object:text-caret-moved"):
            if role == Atspi.Role.LABEL:
                tokens = ["EVENT MANAGER: Ignoring", event_type, "due to role of unfocused source"]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return True
            return False

        if event_type.startswith("object:text-changed"):
            if "insert" in event_type and event.detail2 > 1000:
                tokens = ["EVENT MANAGER: Ignoring", event_type, "due to inserted text size"]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return True
            if event_type.endswith("system") and AXUtilities.is_selectable(focus):
                tokens = ["EVENT MANAGER: Ignoring because", event_type, "is suspected spam"]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return True
            return False

        return None

    def _ignore(self, event: Atspi.Event) -> bool:
        """Returns True if this event should be ignored."""

        debug.print_message(debug.LEVEL_INFO, "")
        tokens = ["EVENT MANAGER:", event]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        if event.type.startswith(("window", "mouse:button")):
            return False

        if not self._active or self._paused:
            msg = "EVENT MANAGER: Ignoring because manager is not active or queueing is paused"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return True

        focus = focus_manager.get_manager().get_locus_of_focus()
        role = AXObject.get_role(event.source)
        for check in (
            lambda: self._ignore_by_role(event, role),
            lambda: self._ignore_by_focus_state(event, focus, role),
            lambda: self._ignore_property_change(event, role),
            lambda: self._ignore_by_spam_filter(event),
            lambda: self._ignore_active_descendant_or_selection(event),
            lambda: self._ignore_children_changed(event, focus),
            lambda: self._ignore_state_changed(event, role),
            lambda: self._ignore_text_events(event, focus, role),
        ):
            result = check()
            if result is not None:
                return result

        return False

    def _queue_println(
        self,
        event: input_event.InputEvent | Atspi.Event,
        is_enqueue: bool = True,
    ) -> None:
        """Convenience method to output queue-related debugging info."""

        if debug.debugLevel > debug.LEVEL_INFO:
            return

        tokens = []
        if isinstance(event, input_event.KeyboardEvent):
            tokens.extend([event.keyval_name, event.hw_code])
        elif isinstance(event, input_event.BrailleEvent):
            tokens.append(event.event)
        else:
            tokens.append(event)

        if is_enqueue:
            tokens[0:0] = ["EVENT MANAGER: Queueing"]
        else:
            tokens[0:0] = ["EVENT MANAGER: Dequeueing"]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

    def _enqueue_object_event(self, e: Atspi.Event) -> None:
        """Callback for Atspi object events."""

        # If we are enqueuing events, we're not dead and should not be killed
        # and restarted by systemd.
        if self._event_queue.qsize() > 75 and systemd.get_manager().is_systemd_managed():
            systemd.get_manager().notify_alive("Event queue size > 75")

        if self._ignore(e):
            return

        self._queue_println(e)
        app = AXUtilities.get_application(e.source)
        tokens = ["EVENT MANAGER: App for event source is", app]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        if AXObject.check_hung(e.source, app):
            tokens = ["EVENT MANAGER: Dropping", e, "from hung source or app"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return

        script = script_manager.get_manager().get_script(app, e.source)
        script.record_queued_event(e)

        priority = self._get_priority(e)
        with self._gidle_lock:
            counter = next(self._counter)
            self._event_queue.put((priority, counter, e))
            if e.type.startswith(EventManager._SKIPPABLE_SAME_TYPE_PREFIXES):
                self._latest_event[(e.type, hash(e.source))] = counter
            if not self._gidle_id:
                self._gidle_id = GLib.idle_add(self._dequeue_object_event)
        tokens = ["EVENT MANAGER: Queued", e, "priority:", priority.name, ", counter:", counter]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

    def _on_no_focus(self) -> bool:
        if not focus_manager.get_manager().focus_and_window_are_unknown():
            return False

        if script_manager.get_manager().get_active_script() is None:
            default_script = script_manager.get_manager().get_default_script()
            script_manager.get_manager().set_active_script(default_script, "No focus")
            braille_presenter.get_presenter().disable_braille()

        return False

    def _dequeue_object_event(self) -> bool:
        """Handles all object events destined for scripts."""

        rerun = True
        try:
            priority, counter, event = self._event_queue.get_nowait()
            self._queue_println(event, is_enqueue=False)
            tokens = [
                "EVENT MANAGER: Dequeued",
                event,
                "priority:",
                priority.name,
                ", counter:",
                counter,
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            start_time = time.time()
            tokens = [
                "\nvvvvv START ",
                priority.name,
                "-PRIORITY OBJECT EVENT",
                event.type.upper(),
                "(queue size:",
                self._event_queue.qsize(),
                ") vvvvv",
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, False)
            self._process_object_event(event, counter)
            tokens = [
                "TOTAL PROCESSING TIME:",
                round(time.time() - start_time, 4),
                "\n^^^^^ FINISHED ",
                priority.name,
                "-PRIORITY OBJECT EVENT",
                event.type.upper(),
                "^^^^^\n",
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, False)
            with self._gidle_lock:
                if self._event_queue.empty():
                    GLib.timeout_add(2500, self._on_no_focus)
                    self._gidle_id = 0
                    rerun = False  # destroy and don't call again
        except queue.Empty:
            msg = "EVENT MANAGER: Attempted dequeue, but the event queue is empty"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            with self._gidle_lock:
                if self._event_queue.empty():
                    self._gidle_id = 0
                    rerun = False  # destroy and don't call again
        except Exception:  # pylint: disable=broad-except
            with self._gidle_lock:
                self._gidle_id = GLib.idle_add(self._dequeue_object_event)
            raise

        return rerun

    def register_listener(self, event_type: str) -> None:
        """Tells this module to listen for the given event type.

        Arguments:
        - event_type: the event type.
        """

        tokens = ["EVENT MANAGER: registering listener for:", event_type]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        if event_type in self._script_listener_counts:
            self._script_listener_counts[event_type] += 1
        else:
            self._listener.register(event_type)
            self._script_listener_counts[event_type] = 1

    def deregister_listener(self, event_type: str) -> None:
        """Tells this module to stop listening for the given event type.

        Arguments:
        - event_type: the event type.
        """

        tokens = ["EVENT MANAGER: deregistering listener for:", event_type]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        if event_type not in self._script_listener_counts:
            return

        self._script_listener_counts[event_type] -= 1
        if self._script_listener_counts[event_type] == 0:
            try:
                self._listener.deregister(event_type)
            except GLib.GError as error:
                tokens = [
                    "EVENT MANAGER: Exception deregistering listener for",
                    event_type,
                    ":",
                    error,
                ]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            del self._script_listener_counts[event_type]

    def register_script_listeners(self, script: default.Script) -> None:
        """Tells the event manager to start listening for all the event types
        of interest to the script.

        Arguments:
        - script: the script.
        """

        tokens = ["EVENT MANAGER: Registering listeners for:", script]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        for event_type in script.listeners:
            self.register_listener(event_type)

    def deregister_script_listeners(self, script: default.Script) -> None:
        """Tells the event manager to stop listening for all the event types
        of interest to the script.

        Arguments:
        - script: the script.
        """

        tokens = ["EVENT MANAGER: De-registering listeners for:", script]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        for event_type in script.listeners:
            self.deregister_listener(event_type)

    def _get_script_for_event(
        self,
        event: Atspi.Event,
        active_script: default.Script | None = None,
    ) -> default.Script | None:
        """Returns the script associated with event."""

        if event.source == focus_manager.get_manager().get_locus_of_focus():
            script = active_script or script_manager.get_manager().get_active_script()
            tokens = ["EVENT MANAGER: Script for event from locus of focus is", script]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return script

        if event.type.startswith("mouse:"):
            mouse_event = input_event.MouseButtonEvent(event)
            script = script_manager.get_manager().get_script(mouse_event.app, mouse_event.window)
            tokens = ["EVENT MANAGER: Script for event is", script]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return script

        script = None
        app = AXUtilities.get_application(event.source)
        if AXUtilities.is_defunct(app):
            tokens = ["EVENT MANAGER:", app, "is defunct. Cannot get script for event."]
            debug.print_tokens(debug.LEVEL_WARNING, tokens, True)
            return None

        tokens = ["EVENT MANAGER: Getting script for event for", app, event.source]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        script = script_manager.get_manager().get_script(app, event.source)
        tokens = ["EVENT MANAGER: Script for event is", script]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        return script

    def _is_activatable_event(
        self,
        event: Atspi.Event,
        script: default.Script | None = None,
    ) -> tuple[bool, str]:
        """Determines if event should cause us to change the active script."""

        if not event.source:
            return False, "event.source? What event.source??"

        if not script:
            script = self._get_script_for_event(event)
            if not script:
                return False, "There is no script for this event."

        app = AXUtilities.get_application(event.source)
        if app and not AXUtilities.is_application_in_desktop(app):
            return False, "The application is unknown to AT-SPI2"

        if not script.is_activatable_event(event):
            return False, "The script says not to activate for this event."

        if script.force_script_activation(event):
            return True, "The script insists it should be activated for this event."

        return self._is_activatable_by_event_type(event)

    @staticmethod
    def _is_activatable_by_event_type(event: Atspi.Event) -> tuple[bool, str]:
        """Determines if event type makes this an activatable event."""

        event_type = event.type

        window_activation = event_type.startswith("window:activate") or (
            event_type.startswith("object:state-changed:active")
            and event.detail1
            and AXUtilities.is_frame(event.source)
        )
        if window_activation:
            is_new = event.source != focus_manager.get_manager().get_active_window()
            reason = (
                "Window activation" if is_new else "Window activation for already-active window"
            )
            return is_new, reason

        if event_type.startswith("object:state-changed:focused") and event.detail1:
            return True, "Event source claimed focus."

        if (
            event_type.startswith("object:state-changed:selected")
            and event.detail1
            and AXUtilities.is_menu(event.source)
            and AXUtilities.is_focusable(event.source)
        ):
            return True, "Selection change in focused menu"

        # This condition appears with gnome-screensaver-dialog. See bug 530368.
        if (
            event_type.startswith("object:state-changed:showing")
            and AXUtilities.is_panel(event.source)
            and AXUtilities.is_modal(event.source)
        ):
            return True, "Modal panel is showing."

        return False, "No reason found to activate a different script."

    def _event_source_is_dead(self, event: Atspi.Event) -> bool:
        if AXObject.is_dead(event.source, AXUtilities.get_application(event.source)):
            tokens = ["EVENT MANAGER: source of", event.type, "is dead"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return True

        return False

    def _should_process_event(
        self,
        event: Atspi.Event,
        event_script: default.Script,
        active_script: default.Script | None,
    ) -> bool:
        """Returns True if this event should be processed."""

        if event_script == active_script:
            tokens = ["EVENT MANAGER: Processing", event.type, ": script for event is active"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return True

        if event_script.present_if_inactive:
            tokens = [
                "EVENT MANAGER: Processing",
                event.type,
                ": script handles events when inactive",
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return True

        if "accessible-value" in event.type and AXUtilities.is_progress_bar(event.source):
            tokens = ["EVENT MANAGER: Processing", event.type, ": source is progress bar"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return True

        tokens = ["EVENT MANAGER: Not processing", event.type, "due to lack of reason"]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        return False

    @staticmethod
    def _handle_early_event_processing(event: Atspi.Event) -> bool:
        """Handles early event processing. Returns True if the event was fully handled."""

        script_mgr = script_manager.get_manager()
        focus_mgr = focus_manager.get_manager()

        event_type = event.type
        if (
            event_type.startswith("object:children-changed:remove")
            and event.source == AXUtilities.get_desktop()
        ):
            script_mgr.reclaim_scripts()
            return True

        app = AXUtilities.get_application(event.source)
        if AXObject.is_dead(event.source, app) or AXUtilities.is_defunct(event.source):
            if (
                event_type.startswith("window:activate")
                and AXObject.object_is_known_dead(event.source)
                and AXObject.revalidate_if_known_dead(event.source)
            ):
                app = AXUtilities.get_application(event.source)
                if not AXObject.is_dead(event.source, app) and not AXUtilities.is_defunct(
                    event.source
                ):
                    return False

            tokens = ["EVENT MANAGER: Ignoring defunct object:", event.source]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)

            if event_type.startswith("window:de") and focus_mgr.get_active_window() == event.source:
                focus_mgr.clear_state("Active window is dead or defunct")
                script_mgr.set_active_script(None, "Active window is dead or defunct")
            return True

        if event_type.startswith("window:") and event_type.endswith("destroy"):
            script_mgr.reclaim_scripts()

        if AXUtilities.is_iconified(event.source):
            tokens = ["EVENT MANAGER: Ignoring iconified object:", event.source]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return True

        return False

    @staticmethod
    def _find_listener(script: default.Script, event_type: str):
        """Returns the listener for event_type, or None."""

        listener = script.listeners.get(event_type)
        if listener is not None:
            return listener

        # The listener can be None if the event type has a suffix such as "system".
        for key, value in script.listeners.items():
            if event_type.startswith(key):
                return value

        return None

    def _process_object_event(self, event: Atspi.Event, counter: int = -1) -> None:
        """Handles all object events destined for scripts."""

        if self._is_obsoleted_by(event, counter) or self._handle_early_event_processing(event):
            return

        if debug.debugLevel <= debug.LEVEL_INFO:
            # The details are still one string. They become tokens when the debug value tree
            # can carry them.
            tokens: list[Any] = [AXUtilitiesDebugging.object_event_details_as_string(event)]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        script_mgr = script_manager.get_manager()
        active_script = script_mgr.get_active_script()
        script = self._get_script_for_event(event, active_script)
        if not script:
            msg = "ERROR: Could not get script for event"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return

        if script != active_script:
            set_new_active_script, reason = self._is_activatable_event(event, script)
            tokens = [
                "EVENT MANAGER: Change active script:",
                set_new_active_script,
                "(",
                reason,
                ")",
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)

            if set_new_active_script:
                script_mgr.set_active_script(script, reason)
                active_script = script

        if not self._should_process_event(event, script, active_script):
            return

        listener = self._find_listener(script, event.type)
        if listener is None:
            tokens = ["EVENT MANAGER: No listener for event type", event.type]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return

        listener(event)


_manager: EventManager = EventManager()


def get_manager() -> EventManager:
    """Returns the Event Manager singleton."""
    return _manager
