#!/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

from __future__ import annotations

__id__        = "$Id$"
__version__   = "$Revision$"
__date__      = "$Date$"
__copyright__ = "Copyright (c) 2011. Cthulhu Team."
__license__   = "LGPL"

import gi
gi.require_version('Atspi', '2.0')
from gi.repository import Atspi
from gi.repository import GLib
import queue
import threading
import time
from typing import TYPE_CHECKING, Optional, Dict, List, Tuple, Any, Union

from . import cthulhu
from . import debug
from . import input_event
from . import input_event_manager
from . import cthulhu_state
from . import script_manager
from . import settings
from .ax_object import AXObject
from .ax_utilities import AXUtilities
from .compositor_state_types import (
    CompositorStateEvent,
    DESKTOP_FOCUS_CONTEXT_CHANGED,
    DESKTOP_TRANSITION_FINISHED,
    DESKTOP_TRANSITION_STARTED,
    FLUSH_STALE_ATSPI_EVENTS,
    PAUSE_ATSPI_CHURN,
    PRIORITIZE_FOCUS,
    RESUME_ATSPI_CHURN,
)

if TYPE_CHECKING:
    from .cthulhu import Cthulhu
    from .script import Script
    from .input_event_manager import InputEventManager

class EventManager:

    EMBEDDED_OBJECT_CHARACTER: str = '\ufffc'
    RELEVANCE_KEEP: str = "keep"
    RELEVANCE_COLLAPSE: str = "collapse"
    RELEVANCE_DROP: str = "drop"

    def __init__(self, app: Cthulhu, asyncMode: bool = True) -> None:
        debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Initializing', True)
        debug.printMessage(debug.LEVEL_INFO, f'EVENT MANAGER: Async Mode is {asyncMode}', True)
        self.app: Cthulhu = app
        self._asyncMode: bool = asyncMode
        self._scriptListenerCounts: Dict[str, int] = {}
        self._active: bool = False
        self._enqueueCount: int = 0
        self._dequeueCount: int = 0
        self._cmdlineCache: Dict[int, str] = {}
        self._eventQueue: queue.Queue[Any] = queue.Queue(0)
        self._prioritizedEvent: Optional[Atspi.Event] = None
        self._gidleId: int = 0
        self._prioritizedIdleId: int = 0
        self._gidleLock: threading.Lock = threading.Lock()
        self._gilSleepTime: float = 0.00001
        self._synchronousToolkits: List[str] = ['VCL']
        self._eventsSuspended: bool = False
        self._listener: Atspi.EventListener = Atspi.EventListener.new(self._enqueue)

        # Note: These must match what the scripts registered for, otherwise
        # Atspi might segfault.
        #
        # Events we don't want to suspend include:
        # object:text-changed:insert - marco
        # object:property-change:accessible-name - gnome-shell issue #6925
        self._suspendableEvents: List[str] = ['object:children-changed:add',
                                               'object:children-changed:remove',
                                               'object:state-changed:sensitive',
                                               'object:state-changed:showing',
                                               'object:text-changed:delete']
        self._eventsTriggeringSuspension: List[Atspi.Event] = []
        self._ignoredEvents: List[str] = ['object:bounds-changed',
                                           'object:state-changed:defunct',
                                           'object:property-change:accessible-parent']
        self._parentsOfDefunctDescendants: List[Atspi.Accessible] = []
        self._compositorStateAdapter: Optional[Any] = None
        self._churnSuppressed: bool = cthulhu_state.pauseAtspiChurn
        self._prioritizedContextToken: Optional[str] = cthulhu_state.prioritizedDesktopContextToken
        self._relevanceBurstWindow: float = 0.15
        self._relevanceBurstHistory: Dict[Tuple[str, str, str], float] = {}

        cthulhu_state.device = None
        self._keyHandlingActive: bool = False
        self._inputEventManager: Optional[InputEventManager] = None

        debug.printMessage(debug.LEVEL_INFO, 'Event manager initialized', True)

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

        debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Activating', True)
        self._activateKeyHandling()
        self._active = True
        GLib.idle_add(self._sync_focus_on_startup)
        debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Activated', True)

    def _sync_focus_on_startup(self) -> bool:
        """Initialize active window and focus when startup missed focus events."""

        focus = cthulhu_state.locusOfFocus
        if focus and not AXObject.is_dead(focus):
            return False

        window = cthulhu_state.activeWindow
        if not AXUtilities.can_be_active_window(window, clear_cache=True):
            window = AXUtilities.find_active_window()
            if window is not None:
                tokens = ["EVENT MANAGER: Setting initial active window to", window]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                cthulhu.setActiveWindow(window, alsoSetLocusOfFocus=True, notifyScript=False)

        if window is None:
            return False

        focused = AXUtilities.get_focused_object(window)
        if focused is not None and focused != cthulhu_state.locusOfFocus:
            cthulhu.setLocusOfFocus(None, focused, notifyScript=True, force=True)
        elif cthulhu_state.locusOfFocus is None:
            cthulhu.setLocusOfFocus(None, window, notifyScript=True, force=True)

        if getattr(self, "_compositorStateAdapter", None) is not None:
            self._compositorStateAdapter.sync_accessible_context("event-manager-startup")

        return False

    def _activateKeyHandling(self) -> None:
        """Activates keyboard handling using InputEventManager with Atspi.Device."""

        if self._keyHandlingActive:
            return

        debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Activating keyboard handling', True)
        self._inputEventManager = input_event_manager.get_manager()
        self._inputEventManager.start_key_watcher()
        cthulhu_state.device = self._inputEventManager._device
        self._keyHandlingActive = True
        debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Keyboard handling activated', True)

    def _deactivateKeyHandling(self) -> None:
        """Deactivates keyboard handling."""

        if not self._keyHandlingActive:
            return

        debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Deactivating keyboard handling', True)
        if self._inputEventManager:
            self._inputEventManager.stop_key_watcher()
        cthulhu_state.device = None
        self._keyHandlingActive = False
        debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Keyboard handling deactivated', True)

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

        debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Deactivating', True)
        self._active = False
        self._eventQueue = queue.Queue(0)
        self._scriptListenerCounts = {}
        self._deactivateKeyHandling()
        debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Deactivated', True)

    def set_compositor_state_adapter(self, adapter: Any) -> None:
        """Stores the compositor state adapter used for startup wiring."""

        if self._compositorStateAdapter is adapter:
            return

        if self._compositorStateAdapter is not None and hasattr(self._compositorStateAdapter, "remove_listener"):
            self._compositorStateAdapter.remove_listener(self._handle_compositor_signal)

        self._compositorStateAdapter = adapter
        if adapter is not None and hasattr(adapter, "add_listener"):
            adapter.add_listener(self._handle_compositor_signal)

    def _handle_compositor_signal(self, signal: CompositorStateEvent) -> None:
        """Updates churn suppression state from compositor normalization signals."""

        snapshot = signal.snapshot
        signalType = signal.type
        contextToken = getattr(signal, "context_token", None)
        if contextToken is None and signal.payload:
            contextToken = signal.payload.get("context_token")
        if contextToken is None and snapshot is not None:
            contextToken = snapshot.active_window_token or snapshot.locus_of_focus_token or None
        if snapshot is not None:
            snapshotToken = snapshot.active_window_token or snapshot.locus_of_focus_token or ""
            if not contextToken:
                contextToken = snapshotToken or None

        if signalType in (DESKTOP_FOCUS_CONTEXT_CHANGED, PRIORITIZE_FOCUS):
            self._prioritizedContextToken = contextToken
            cthulhu_state.prioritizedDesktopContextToken = contextToken
            return

        if signalType in (PAUSE_ATSPI_CHURN, DESKTOP_TRANSITION_STARTED):
            self._churnSuppressed = True
            cthulhu_state.pauseAtspiChurn = True
            if contextToken:
                self._prioritizedContextToken = contextToken
                cthulhu_state.prioritizedDesktopContextToken = contextToken
            return

        if signalType in (RESUME_ATSPI_CHURN, DESKTOP_TRANSITION_FINISHED):
            self._churnSuppressed = False
            cthulhu_state.pauseAtspiChurn = False
            if contextToken:
                self._prioritizedContextToken = contextToken
                cthulhu_state.prioritizedDesktopContextToken = contextToken
            if signalType == DESKTOP_TRANSITION_FINISHED:
                self._flush_stale_atspi_events()
            return

        if signalType == FLUSH_STALE_ATSPI_EVENTS:
            self._flush_stale_atspi_events()

    def ignoreEventTypes(self, eventTypeList: List[str]) -> None:
        for eventType in eventTypeList:
            if eventType not in self._ignoredEvents:
                self._ignoredEvents.append(eventType)

    def unignoreEventTypes(self, eventTypeList: List[str]) -> None:
        for eventType in eventTypeList:
            if eventType in self._ignoredEvents:
                self._ignoredEvents.remove(eventType)

    def _isDuplicateEvent(self, event: Atspi.Event) -> bool:
        """Returns True if this event is already in the event queue."""

        if self._inFlood() and self._prioritizeDuringFlood(event):
            return False

        def isSame(x: Any) -> bool:
            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

        for e in self._eventQueue.queue:
            if isSame(e):
                return True

        return False

    def _getAppCmdline(self, app: Atspi.Accessible) -> str:
        pid = AXObject.get_process_id(app)
        if pid == -1:
            return ""

        cmdline = self._cmdlineCache.get(pid)
        if cmdline is None:
            cmdline = debug.getCmdline(pid)
            self._cmdlineCache[pid] = cmdline
        return cmdline

    def _isSteamApp(self, app: Atspi.Accessible) -> bool:
        name = AXObject.get_name(app)
        if not name:
            nameLower = ""
        else:
            nameLower = name.lower()

        if "steamwebhelper" in nameLower or nameLower in ("steam", "steam web helper"):
            return True

        cmdline = self._getAppCmdline(app)
        return "steamwebhelper" in cmdline

    def _isSteamNotificationEvent(self, event: Atspi.Event) -> bool:
        return self._isLiveOrNotificationEvent(event)

    def _isLiveOrNotificationObject(self, obj: Any) -> bool:
        if obj is None or isinstance(obj, (str, bytes, int, float, bool)):
            return False

        try:
            if AXUtilities.is_notification(obj) or AXUtilities.is_alert(obj):
                return True
        except Exception:
            pass

        try:
            liveAttr = AXObject.get_attribute(obj, 'live')
            containerLive = AXObject.get_attribute(obj, 'container-live')
        except Exception:
            return isinstance(obj, Atspi.Accessible)

        if liveAttr in ('assertive', 'polite') or containerLive in ('assertive', 'polite'):
            return True

        return False

    def _isLiveOrNotificationEvent(self, event: Atspi.Event) -> bool:
        for obj in (event.any_data, event.source):
            if self._isLiveOrNotificationObject(obj):
                return True

        return False

    def _isPhaseOneWebRole(self, role: Atspi.Role) -> bool:
        return role in (
            Atspi.Role.LINK,
            Atspi.Role.PUSH_BUTTON,
            Atspi.Role.CHECK_BOX,
            Atspi.Role.RADIO_BUTTON,
            Atspi.Role.LIST_ITEM,
            Atspi.Role.LIST,
            Atspi.Role.SECTION,
        )

    def _eventTouchesCurrentFocusContext(self, event: Atspi.Event) -> bool:
        locusOfFocus = cthulhu_state.locusOfFocus
        if locusOfFocus is None:
            return False

        if event.source in (locusOfFocus, getattr(event, "any_data", None)):
            return True

        focused = getattr(cthulhu_state, "pendingSelfHostedFocus", None)
        if focused is not None and event.source in (focused, getattr(event, "any_data", None)):
            return True

        return False

    def _shouldPreserveForRelevanceGate(self, event: Atspi.Event) -> bool:
        if event.type.startswith("window"):
            return True

        if event.type.startswith("mouse:button"):
            return True

        if event.type.startswith("object:state-changed:focused") and event.detail1:
            return True

        if self._eventTouchesCurrentFocusContext(event):
            return True

        return self._isLiveOrNotificationEvent(event)

    def _eventRoleForRelevance(self, event: Atspi.Event) -> Optional[Atspi.Role]:
        try:
            return AXObject.get_role(event.source)
        except Exception:
            return None

    def _appNameForRelevance(self, app: Atspi.Accessible) -> str:
        try:
            appName = AXObject.get_name(app)
        except Exception:
            return ""

        return (appName or "").strip().lower()

    def _relevanceEventGroup(self, event: Atspi.Event, role: Atspi.Role) -> Optional[str]:
        if event.type.startswith("object:children-changed:") and self._isPhaseOneWebRole(role):
            return "children-changed"

        if event.type.startswith("object:property-change:accessible-name") and self._isPhaseOneWebRole(role):
            return "accessible-name"

        return None

    def _classifyRelevance(self, event: Atspi.Event, app: Atspi.Accessible) -> str:
        if self._shouldPreserveForRelevanceGate(event):
            return self.RELEVANCE_KEEP

        if event.type.startswith("object:state-changed:focused"):
            if event.detail1 or cthulhu_state.locusOfFocus is None:
                return self.RELEVANCE_KEEP
            if self._eventTouchesCurrentFocusContext(event):
                return self.RELEVANCE_KEEP
            return self.RELEVANCE_DROP

        role = self._eventRoleForRelevance(event)
        eventGroup = self._relevanceEventGroup(event, role)
        if eventGroup is None:
            return self.RELEVANCE_KEEP

        if event.type.startswith("object:property-change:accessible-name") \
           and self._isPhaseOneWebRole(role):
            try:
                focused = AXUtilities.is_focused(event.source)
            except Exception:
                focused = False
            if not focused:
                return self.RELEVANCE_DROP

        if self._isBurstRelevanceEvent(event, app, eventGroup):
            return self.RELEVANCE_COLLAPSE

        return self.RELEVANCE_KEEP

    def _isBurstRelevanceEvent(
        self,
        event: Atspi.Event,
        app: Atspi.Accessible,
        eventGroup: Optional[str] = None,
    ) -> bool:
        if self._shouldPreserveForRelevanceGate(event):
            return False

        if eventGroup is None:
            role = self._eventRoleForRelevance(event)
            eventGroup = self._relevanceEventGroup(event, role)

        if eventGroup is None:
            return False

        appName = self._appNameForRelevance(app) or "unknown-app"
        try:
            contextKey = self._context_token_for_event(event)
        except Exception:
            contextKey = None
        contextKey = contextKey or f"source:{id(event.source)}"
        burstKey = (appName, eventGroup, contextKey)
        now = time.monotonic()
        lastSeen = self._relevanceBurstHistory.get(burstKey)
        self._relevanceBurstHistory[burstKey] = now
        return lastSeen is not None and (now - lastSeen) < self._relevanceBurstWindow

    def _isSteamBurstChurnEvent(self, event: Atspi.Event, app: Atspi.Accessible) -> bool:
        if not self._isSteamApp(app) or self._isLiveOrNotificationEvent(event):
            return False

        if event.type.startswith("object:state-changed:focused"):
            return not event.detail1

        if event.type.startswith("object:children-changed:"):
            return self._isBurstRelevanceEvent(event, app, "children-changed")

        return False

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

        app = AXObject.get_application(event.source)
        debug.printMessage(debug.LEVEL_INFO, '')
        tokens = ["EVENT MANAGER:", event.type, "from", app]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

        def _log_ignore(reason: str, message: str) -> None:
            debug.print_log(debug.LEVEL_INFO, "EVENT MANAGER",
                            f"Ignoring - {message}", reason, True)

        def _log_allow(reason: str, message: str) -> None:
            debug.print_log(debug.LEVEL_INFO, "EVENT MANAGER",
                            f"Not ignoring - {message}", reason, True)

        def _ignore_with_reason(reason: str, message: str) -> bool:
            _log_ignore(reason, message)
            return True

        def _allow_with_reason(reason: str, message: str) -> bool:
            _log_allow(reason, message)
            return False

        if self._eventsSuspended:
            tokens = ["EVENT MANAGER: Suspended events:", ', '.join(self._suspendableEvents)]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)

        if not self._active:
            return _ignore_with_reason("inactive", "event manager is not active")

        if list(filter(event.type.startswith, self._ignoredEvents)):
            return _ignore_with_reason("type-ignored", "event type is ignored")

        if AXObject.get_name(app) == 'gnome-shell':
            if event.type.startswith('object:children-changed:remove'):
                return _ignore_with_reason("gnome-shell", "children-changed:remove")

        if event.type.startswith('window'):
            return _allow_with_reason("window-event", "event type is never ignored")

        if event.type.startswith('mouse:button'):
            return _allow_with_reason("mouse-event", "event type is never ignored")

        if self._is_obsolete_by_context(event):
            return _ignore_with_reason("compositor-stale-context", "event is from a stale compositor context")

        if self._churnSuppressed and not self._should_preserve_during_suppression(event):
            return _ignore_with_reason("compositor-churn-paused", "event is suppressed during compositor churn")

        if self._isDuplicateEvent(event):
            return _ignore_with_reason("duplicate", "duplicate event")

        if self._isSelfHostedFocusClearedEvent(event):
            return _ignore_with_reason("self-hosted-focus-cleared", "self-hosted focused=false event")

        if self._isRedundantSelfHostedPropertyEvent(event):
            return _ignore_with_reason("self-hosted-redundant-property", "self-hosted redundant property event")

        # Thunderbird spams us with these when a message list thread is expanded or collapsed.
        if event.type.endswith('system') \
           and AXObject.get_name(app).lower().startswith('thunderbird'):
            if AXUtilities.is_table_related(event.source) \
              or AXUtilities.is_tree_related(event.source) \
              or AXUtilities.is_section(event.source):
                return _ignore_with_reason("thunderbird-system", "system event based on role")

        if self._inDeluge() and self._ignoreDuringDeluge(event):
            return _ignore_with_reason("deluge", "event type during deluge")

        script = cthulhu_state.activeScript
        if event.type.startswith('object:children-changed') \
           or event.type.startswith('object:state-changed:sensitive'):
            if not script:
                return _ignore_with_reason("no-active-script", "no active script")
            if script.app != app:
                # Allow Steam notifications from inactive apps.
                if self._isSteamApp(app) and self._isSteamNotificationEvent(event):
                    _log_allow("steam-notification", "inactive app notification")
                else:
                    return _ignore_with_reason("inactive-app", "event not from active app")

        relevance = self._classifyRelevance(event, app)
        if relevance == self.RELEVANCE_DROP:
            return _ignore_with_reason("event-relevance-drop", "event is background churn")
        if relevance == self.RELEVANCE_COLLAPSE:
            return _ignore_with_reason("event-relevance-collapse", "event was collapsed by relevance gate")

        if self._isSteamBurstChurnEvent(event, app):
            return _ignore_with_reason("steam-burst-churn", "event is low-value Steam churn")

        if event.type.startswith('object:text-changed') \
           and self.EMBEDDED_OBJECT_CHARACTER in event.any_data \
           and not event.any_data.replace(self.EMBEDDED_OBJECT_CHARACTER, ""):
            # We should also get children-changed events telling us the same thing.
            # Getting a bunch of both can result in a flood that grinds us to a halt.
            return _ignore_with_reason("embedded-only", "changed text only embedded objects")

        # TODO - JD: For now we won't ask for the name. Simply asking for the name should
        # not break anything, and should be a reliable way to quickly identify defunct
        # objects. But apparently the mere act of asking for the name causes Cthulhu to stop
        # presenting Eclipse (and possibly other) applications. This might be an AT-SPI2
        # issue, but until we know for certain....
        #name = Atspi.Accessible.get_name(event.source)

        if AXUtilities.has_no_state(event.source):
            return _ignore_with_reason("empty-state", "empty state set")

        if AXUtilities.is_defunct(event.source):
            return _ignore_with_reason("defunct-source", "defunct source")

        role = AXObject.get_role(event.source)
        if event.type.startswith('object:property-change:accessible-name'):
            if role in [Atspi.Role.CANVAS,
                        Atspi.Role.ICON,
                        Atspi.Role.LIST_ITEM,  # Web app spam
                        Atspi.Role.LIST,       # Web app spam
                        Atspi.Role.PANEL,      # TeamTalk5 spam
                        Atspi.Role.SECTION,    # Web app spam
                        Atspi.Role.TABLE_ROW,  # Thunderbird spam
                        Atspi.Role.TABLE_CELL, # Thunderbird spam
                        Atspi.Role.TREE_ITEM,  # Thunderbird spam
                        Atspi.Role.IMAGE,      # Thunderbird spam
                        Atspi.Role.MENU,
                        Atspi.Role.MENU_ITEM]:
                return _ignore_with_reason("name-change-role", "role filtered")
            # TeamTalk5 is notoriously spammy here, and name change events on widgets are
            # typically only presented if they are focused.
            if not AXUtilities.is_focused(event.source) \
               and role in [Atspi.Role.PUSH_BUTTON,
                            Atspi.Role.CHECK_BOX,
                            Atspi.Role.RADIO_BUTTON]:
                return _ignore_with_reason("name-change-unfocused", "role and state")
        elif event.type.startswith('object:property-change:accessible-value'):
            if role == Atspi.Role.SPLIT_PANE and not AXUtilities.is_focused(event.source):
                return _ignore_with_reason("value-change-unfocused", "role and state")
        elif event.type.startswith('object:text-changed:insert') and event.detail2 > 1000 \
             and role in [Atspi.Role.TEXT, Atspi.Role.STATIC]:
            return _ignore_with_reason("text-insert-large", "inserted text > 1000 chars")
        elif event.type.startswith('object:state-changed:sensitive'):
            if role in [Atspi.Role.MENU_ITEM,
                        Atspi.Role.MENU,
                        Atspi.Role.FILLER,
                        Atspi.Role.PANEL,
                        Atspi.Role.CHECK_MENU_ITEM,
                        Atspi.Role.RADIO_MENU_ITEM]:
                return _ignore_with_reason("sensitive-role", "role filtered")
        elif event.type.startswith('object:state-changed:selected'):
            if not event.detail1 and role in [Atspi.Role.PUSH_BUTTON]:
                return _ignore_with_reason("selected-button-false", "role and detail1")
        elif event.type.startswith('object:state-changed:showing'):
            if role not in [Atspi.Role.ALERT,
                            Atspi.Role.ANIMATION,
                            Atspi.Role.INFO_BAR,
                            Atspi.Role.MENU,
                            Atspi.Role.NOTIFICATION,
                            Atspi.Role.DIALOG,
                            Atspi.Role.STATUS_BAR,
                            Atspi.Role.TOOL_TIP]:
                return _ignore_with_reason("showing-role", "role filtered")

        elif event.type.startswith('object:text-caret-moved'):
            if role in [Atspi.Role.LABEL] and not AXUtilities.is_focused(event.source):
                return _ignore_with_reason("caret-unfocused-label", "role and state")

        elif event.type.startswith('object:selection-changed'):
            if event.source in self._parentsOfDefunctDescendants:
                return _ignore_with_reason("defunct-descendant-parent", "parent of defunct descendants")

            if AXObject.is_dead(event.source):
                return _ignore_with_reason("dead-source", "dead source")

        if event.type.startswith('object:children-changed') \
           or event.type.startswith('object:active-descendant-changed'):
            if role in [Atspi.Role.MENU,
                        Atspi.Role.LAYERED_PANE,
                        Atspi.Role.MENU_ITEM]:
                return _ignore_with_reason("children-role", "role filtered")
            if event.any_data is None:
                return _ignore_with_reason("missing-any-data", "no event.any_data")
            if event.type.endswith('remove'):
                if event.any_data == cthulhu_state.locusOfFocus:
                    msg = 'EVENT MANAGER: Locus of focus is being destroyed'
                    debug.printMessage(debug.LEVEL_INFO, msg, True)
                    return False

                if AXObject.is_dead(cthulhu_state.locusOfFocus):
                    msg = 'EVENT MANAGER: Locus of focus is dead.'
                    debug.printMessage(debug.LEVEL_INFO, msg, True)
                    return False

                tokens = ["EVENT MANAGER: Locus of focus:", cthulhu_state.locusOfFocus]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)

            elif event.any_data == cthulhu_state.locusOfFocus:
                msg = 'EVENT MANAGER: Locus of focus is being added.'
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return False

            elif event.any_data == cthulhu_state.pendingSelfHostedFocus:
                msg = 'EVENT MANAGER: Pending self-hosted focus is being added.'
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return False

            elif self._eventsSuspended:
                if event.type.endswith('add') and not self._eventTypeIsSuspended(event.type):
                    # Cthulhu is known to miss text insertion in Gtk3 when text insertions
                    # are suspended. So while we do need to suspend some of the resulting
                    # events, we also need to make sure the text insertion isn't suspended.
                    if AXUtilities.is_text(event.any_data):
                        return _ignore_with_reason("suspended-text", "child text is not suspended")
                elif not self._eventTypeIsSuspended(event.type):
                    return _ignore_with_reason("suspended-event-type", "event type is not suspended")

        return False

    def _addToQueue(self, event: Any, asyncMode: bool) -> None:
        debugging = debug.debugEventQueue
        if debugging:
            debug.printMessage(debug.LEVEL_ALL, "           acquiring lock...")
        self._gidleLock.acquire()

        if debugging:
            debug.printMessage(debug.LEVEL_ALL, "           ...acquired")
            debug.printMessage(debug.LEVEL_ALL, "           calling queue.put...")
            debug.printMessage(debug.LEVEL_ALL, "           (full=%s)" \
                          % self._eventQueue.full())

        self._eventQueue.put(event)
        if debugging:
            debug.printMessage(debug.LEVEL_ALL, "           ...put complete")

        if asyncMode and not self._gidleId:
            if self._gilSleepTime:
                time.sleep(self._gilSleepTime)
            self._gidleId = GLib.idle_add(self._dequeue)

        if debugging:
            debug.printMessage(debug.LEVEL_ALL, "           releasing lock...")
        self._gidleLock.release()
        if debug.debugEventQueue:
            debug.printMessage(debug.LEVEL_ALL, "           ...released")

    def _queuePrintln(self, e: Any, isEnqueue: bool = True, isPrune: Optional[bool] = None) -> None:
        """Convenience method to output queue-related debugging info."""

        if debug.LEVEL_INFO < debug.debugLevel:
            return

        tokens = []
        if isinstance(e, input_event.KeyboardEvent):
            tokens.extend([e.event_string, e.hw_code])
        elif isinstance(e, input_event.BrailleEvent):
            tokens.append(e.event)
        elif not debug.eventDebugFilter or debug.eventDebugFilter.match(e.type):
            tokens.append(e)
        else:
            return

        if isPrune:
            tokens[0:0] = ["EVENT MANAGER: Pruning"]
        elif isPrune is not None:
            tokens[0:0] = ["EVENT MANAGER: Not pruning"]
        elif isEnqueue:
            tokens[0:0] = ["EVENT MANAGER: Queueing"]
        else:
            tokens[0:0] = ["EVENT MANAGER: Dequeued"]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

    def _suspendEvents(self, triggeringEvent: Atspi.Event) -> None:
        self._eventsTriggeringSuspension.append(triggeringEvent)

        if self._eventsSuspended:
            msg = "EVENT MANAGER: Events already suspended."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return

        msg = "EVENT MANAGER: Suspending events."
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        for event in self._suspendableEvents:
            self.deregisterListener(event)

        self._eventsSuspended = True

    def _unsuspendEvents(self, triggeringEvent: Atspi.Event, force: bool = False) -> None:
        if triggeringEvent in self._eventsTriggeringSuspension:
            self._eventsTriggeringSuspension.remove(triggeringEvent)

        if not self._eventsSuspended:
            msg = "EVENT MANAGER: Events already unsuspended."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return

        if self._eventsTriggeringSuspension and not force:
            msg = "EVENT MANAGER: Events are suspended for another event."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return

        msg = "EVENT MANAGER: Unsuspending events."
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        for event in self._suspendableEvents:
            self.registerListener(event)

        self._eventsSuspended = False

    def _shouldSuspendEventsFor(self, event: Atspi.Event) -> bool:
        if AXUtilities.is_frame(event.source) \
           or (AXUtilities.is_window(event.source) \
               and AXUtilities.get_application_toolkit_name(event.source) == "clutter"):
            if event.type.startswith("window"):
                msg = "EVENT MANAGER: Should suspend events for window event."
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True
            if event.type.endswith("active"):
                msg = "EVENT MANAGER: Should suspend events for active event on window."
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True
        if AXUtilities.is_document(event.source):
            if event.type.endswith("busy") and event.detail1:
                msg = "EVENT MANAGER: Should suspend events for busy:true event on document."
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True

        return False

    def _shouldUnsuspendEventsFor(self, event: Atspi.Event) -> bool:
        if event.type.startswith("object:state-changed:focused") and event.detail1:
            msg = "EVENT MANAGER: Should unsuspend events for newly-focused object."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if AXUtilities.is_document(event.source):
            if event.type.endswith("busy") and not event.detail1:
                msg = "EVENT MANAGER: Should unsuspend events for busy:false event on document."
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True
            if event.type.startswith("document:load-complete"):
                msg = "EVENT MANAGER: Should unsuspend events for load-complete event on document."
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True

        return False

    def _didSuspendEventsFor(self, event: Atspi.Event) -> bool:
        return event in self._eventsTriggeringSuspension

    def _enqueue(self, e: Atspi.Event) -> None:
        """Handles the enqueueing of all events destined for scripts.

        Arguments:
        - e: an at-spi event.
        """

        if debug.debugEventQueue:
            if self._enqueueCount:
                msg = f"EVENT MANAGER: _enqueue entered before exiting (count={self._enqueueCount})"
                debug.printMessage(debug.LEVEL_ALL, msg, True)
            self._enqueueCount += 1

        inputEvents = (input_event.KeyboardEvent, input_event.BrailleEvent)
        isObjectEvent = not isinstance(e, inputEvents)

        try:
            ignore = isObjectEvent and self._ignore(e)
        except Exception as error:
            tokens = ["EVENT MANAGER: Exception evaluating event:", e, ":", error]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            ignore = True
        if ignore:
            if debug.debugEventQueue:
                self._enqueueCount -= 1
            return

        if isObjectEvent and self._prioritizeSelfHostedFocusedEvent(e):
            if debug.debugEventQueue:
                self._enqueueCount -= 1
            return

        self._queuePrintln(e)

        if self._inFlood() and self._prioritizeDuringFlood(e):
            msg = 'EVENT MANAGER: Pruning event queue due to flood.'
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            self._pruneEventsDuringFlood()

        if isObjectEvent and self._shouldSuspendEventsFor(e):
            self._suspendEvents(e)

        asyncMode = self._asyncMode
        if isObjectEvent:
            if isinstance(e, input_event.MouseButtonEvent):
                asyncMode = True
            elif AXUtilities.get_application_toolkit_name(e.source) in self._synchronousToolkits:
                asyncMode = False
            elif e.type.startswith("object:children-changed"):
                asyncMode = AXUtilities.is_table(e.source)
            elif AXUtilities.is_notification(e.source):
                # To decrease the likelihood that the popup will be destroyed before we
                # have its contents.
                asyncMode = False
            script = cthulhu.cthulhuApp.scriptManager.get_script(AXObject.get_application(e.source), e.source)
            script.eventCache[e.type] = (e, time.time())

        self._addToQueue(e, asyncMode)
        if not asyncMode:
            self._dequeue()

        if debug.debugEventQueue:
            self._enqueueCount -= 1

    def _isSelfHostedFocusedEvent(self, event: Atspi.Event) -> bool:
        """Returns True if the event is a newly-focused event from Cthulhu."""

        if not event.type.startswith("object:state-changed:focused") or not event.detail1:
            return False

        app = AXObject.get_application(event.source)
        appName = (AXObject.get_name(app) or "").lower()
        return appName == "cthulhu"

    def _isSelfHostedFocusClearedEvent(self, event: Atspi.Event) -> bool:
        """Returns True if the event is a focus-lost event from Cthulhu."""

        if not event.type.startswith("object:state-changed:focused") or event.detail1:
            return False

        app = AXObject.get_application(event.source)
        appName = (AXObject.get_name(app) or "").lower()
        return appName == "cthulhu"

    def _isRedundantSelfHostedPropertyEvent(self, event: Atspi.Event) -> bool:
        """Returns True if the event is redundant prefs startup noise from Cthulhu."""

        app = AXObject.get_application(event.source)
        appName = (AXObject.get_name(app) or "").lower()
        if appName != "cthulhu":
            return False

        if event.type.startswith("object:property-change:accessible-name"):
            return AXUtilities.is_combo_box(event.source) or AXUtilities.is_table_cell(event.source)

        if event.type.startswith("object:property-change:accessible-value"):
            return AXObject.get_role(event.source) == Atspi.Role.SLIDER \
                and event.source != cthulhu_state.locusOfFocus

        return False

    def _prioritizeSelfHostedFocusedEvent(self, event: Atspi.Event) -> bool:
        """Schedules the latest focused-child event from Cthulhu ahead of the normal queue."""

        if not self._isSelfHostedFocusedEvent(event):
            return False

        tokens = ["EVENT MANAGER: Prioritizing self-hosted focused event for", event.source]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

        script = self._get_scriptForEvent(event)
        if script is not None:
            script.eventCache[event.type] = (event, time.time())

        self._gidleLock.acquire()
        try:
            replaced = self._prioritizedEvent is not None
            self._prioritizedEvent = event
            cthulhu_state.pendingSelfHostedFocus = event.source
            if not self._prioritizedIdleId:
                self._prioritizedIdleId = GLib.idle_add(
                    self._dequeuePrioritizedEvent,
                    priority=GLib.PRIORITY_HIGH_IDLE,
                )
        finally:
            self._gidleLock.release()

        msg = f"EVENT MANAGER: Prioritized self-hosted focused event. Replaced pending event: {replaced}"
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        return True
    def _isNoFocus(self) -> bool:
        if cthulhu_state.locusOfFocus or cthulhu_state.activeWindow or cthulhu_state.activeScript:
            return False

        msg = 'EVENT MANAGER: No focus'
        debug.printMessage(debug.LEVEL_SEVERE, msg, True)
        return True

    def _onNoFocus(self) -> bool:
        if not self._isNoFocus():
            return False

        defaultScript = cthulhu.cthulhuApp.scriptManager.get_default_script()
        cthulhu.cthulhuApp.scriptManager.set_active_script(defaultScript, 'focus: none')
        defaultScript.idleMessage()
        return False

    def _dequeuePrioritizedEvent(self) -> bool:
        """Handles prioritized focused events from Cthulhu-owned windows."""

        self._gidleLock.acquire()
        try:
            event = self._prioritizedEvent
            self._prioritizedEvent = None
            self._prioritizedIdleId = 0
            if event is not None and cthulhu_state.pendingSelfHostedFocus == event.source:
                cthulhu_state.pendingSelfHostedFocus = None
        finally:
            self._gidleLock.release()

        if event is None:
            return False

        tokens = ["EVENT MANAGER: Dequeued prioritized event", event]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

        debugging = not debug.eventDebugFilter or debug.eventDebugFilter.match(event.type)
        if debugging:
            startTime = time.time()
            msg = (
                f"\nvvvvv PROCESS OBJECT EVENT {event.type} "
                f"(prioritized) vvvvv"
            )
            debug.printMessage(debug.eventDebugLevel, msg, False)

        try:
            self._processObjectEvent(event)
            if self._didSuspendEventsFor(event):
                self._unsuspendEvents(event)
            elif self._eventsSuspended and self._shouldUnsuspendEventsFor(event):
                self._unsuspendEvents(event, force=True)
        except Exception:
            debug.printException(debug.LEVEL_SEVERE)

        if debugging:
            msg = (
                f"TOTAL PROCESSING TIME: {time.time() - startTime:.4f}"
                f"\n^^^^^ PROCESS OBJECT EVENT {event.type} ^^^^^\n"
            )
            debug.printMessage(debug.eventDebugLevel, msg, False)

        self._gidleLock.acquire()
        try:
            hasMore = self._prioritizedEvent is not None
            if not hasMore and self._eventQueue.qsize() and not self._gidleId:
                self._gidleId = GLib.idle_add(self._dequeue)
        finally:
            self._gidleLock.release()

        return hasMore

    def _dequeue(self) -> bool:
        """Handles all events destined for scripts. Called by the GTK
        idle thread."""

        rerun = True

        if debug.debugEventQueue:
            msg = f"EVENT MANAGER: Dequeue {self._dequeueCount}"
            debug.printMessage(debug.LEVEL_ALL, msg, True)
            self._dequeueCount += 1

        try:
            fromPriority = False
            self._gidleLock.acquire()
            try:
                event = self._eventQueue.get_nowait()
            finally:
                self._gidleLock.release()

            self._queuePrintln(event, isEnqueue=False)
            inputEvents = (input_event.KeyboardEvent, input_event.BrailleEvent)
            if isinstance(event, inputEvents):
                self._processInputEvent(event)
            else:
                debug.objEvent = event
                debugging = not debug.eventDebugFilter \
                            or debug.eventDebugFilter.match(event.type)
                if debugging:
                    startTime = time.time()
                    msg = (
                        f"\nvvvvv PROCESS OBJECT EVENT {event.type} "
                        f"(queue size: {self._eventQueue.qsize()}) vvvvv"
                    )
                    debug.printMessage(debug.eventDebugLevel, msg, False)
                self._processObjectEvent(event)
                if self._didSuspendEventsFor(event):
                    self._unsuspendEvents(event)
                elif self._eventsSuspended and self._shouldUnsuspendEventsFor(event):
                    self._unsuspendEvents(event, force=True)

                if debugging:
                    msg = (
                        f"TOTAL PROCESSING TIME: {time.time() - startTime:.4f}"
                        f"\n^^^^^ PROCESS OBJECT EVENT {event.type} ^^^^^\n"
                    )
                    debug.printMessage(debug.eventDebugLevel, msg, False)

                debug.objEvent = None

            self._gidleLock.acquire()
            if self._eventQueue.empty():
                GLib.timeout_add(2500, self._onNoFocus)
                self._gidleId = 0
                rerun = False # destroy and don't call again
            self._gidleLock.release()
        except queue.Empty:
            msg = 'EVENT MANAGER: Attempted dequeue, but the event queue is empty'
            debug.printMessage(debug.LEVEL_SEVERE, msg, True)
            self._gidleId = 0
            rerun = False # destroy and don't call again
        except Exception:
            debug.printException(debug.LEVEL_SEVERE)

        if debug.debugEventQueue:
            self._dequeueCount -= 1
            msg = f"EVENT MANAGER: Leaving _dequeue. Count: {self._dequeueCount}"
            debug.printMessage(debug.LEVEL_ALL, msg, True)

        return rerun

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

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

        msg = f'EVENT MANAGER: registering listener for: {eventType}'
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        if eventType in self._scriptListenerCounts:
            self._scriptListenerCounts[eventType] += 1
        else:
            self._listener.register(eventType)
            self._scriptListenerCounts[eventType] = 1

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

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

        msg = f'EVENT MANAGER: deregistering listener for: {eventType}'
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        if eventType not in self._scriptListenerCounts:
            return

        self._scriptListenerCounts[eventType] -= 1
        if self._scriptListenerCounts[eventType] == 0:
            self._listener.deregister(eventType)
            del self._scriptListenerCounts[eventType]

    def registerScriptListeners(self, script: 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.printTokens(debug.LEVEL_INFO, tokens, True)

        for eventType in script.listeners.keys():
            self.registerListener(eventType)

    def deregisterScriptListeners(self, script: 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.printTokens(debug.LEVEL_INFO, tokens, True)

        for eventType in script.listeners.keys():
            self.deregisterListener(eventType)

    def _processInputEvent(self, event: Any) -> None:
        """Processes the given input event based on the keybinding from the
        currently-active script.

        Arguments:
        - event: an instance of BrailleEvent or a KeyboardEvent
        """

        if not cthulhu_state.activeScript:
            return

        if not isinstance(event, input_event.BrailleEvent):
            return

        data = f"'{repr(event.event)}'"
        eType = str(event.type).upper()
        startTime = time.time()

        msg = f"\nvvvvv PROCESS {eType} {data} vvvvv"
        debug.printMessage(debug.eventDebugLevel, msg, False)

        try:
            cthulhu_state.activeScript.processBrailleEvent(event)
        except Exception as error:
            tokens = ["EVENT MANAGER: Exception processing event:", error]
            debug.printTokens(debug.LEVEL_WARNING, tokens, True)

        msg = (
            f"TOTAL PROCESSING TIME: {time.time() - startTime:.4f}"
            f"^^^^^ PROCESS {eType} {data} ^^^^^\n"
        )
        debug.printMessage(debug.eventDebugLevel, msg, False)

    @staticmethod
    def _get_scriptForEvent(event: Any) -> Optional[Script]:
        """Returns the script associated with event."""

        if event.type.startswith("mouse:"):
            return cthulhu.cthulhuApp.scriptManager.get_script_for_mouse_button_event(event)

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

        skipCheck = {
            "object:children-changed",
            "object:column-reordered",
            "object:row-reordered",
            "object:property-change",
            "object:selection-changed",
            "object:state-changed:checked",
            "object:state-changed:expanded",
            "object:state-changed:indeterminate",
            "object:state-changed:pressed",
            "object:state-changed:selected",
            "object:state-changed:sensitive",
            "object:state-changed:showing",
            "object:text-changed",
        }

        check = not any(event.type.startswith(x) for x in skipCheck)
        tokens = ["EVENT MANAGER: Getting script for", app, "check:", check]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

        script = cthulhu.cthulhuApp.scriptManager.get_script(app, event.source, sanity_check=check)
        tokens = ["EVENT MANAGER: Script is ", script]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        return script

    def _isActivatableEvent(self, event: Atspi.Event, script: Optional[Script] = None) -> Tuple[bool, str]:
        """Determines if the event is one which should cause us to
        change which script is currently active.

        Returns a (boolean, string) tuple indicating whether or not
        this is an activatable event, and our reason (for the purpose
        of debugging).
        """

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

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

        prioritizedContextToken = self._prioritizedContextToken or cthulhu_state.prioritizedDesktopContextToken
        if self._churnSuppressed and prioritizedContextToken:
            eventToken = self._context_token_for_event(event)
            if eventToken not in (None, prioritizedContextToken):
                return False, "Event context does not match compositor-prioritized context."

        if script == cthulhu_state.activeScript:
            return False, "The script for this event is already active."

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

        if cthulhu_state.activeScript is None:
            active_window = cthulhu_state.activeWindow
            if AXUtilities.is_focused(event.source):
                return True, "No active script and event source is focused."
            if active_window and AXObject.is_ancestor(event.source, active_window, inclusive=True):
                return True, "No active script and event is in active window."
            if AXUtilities.is_frame(event.source) or AXUtilities.is_window(event.source):
                return True, "No active script and event source is window/frame."

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

        eType = event.type

        if eType.startswith('window:activate'):
            windowActivation = True
        else:
            windowActivation = eType.startswith('object:state-changed:active') \
                and event.detail1 and AXUtilities.is_frame(event.source)

        if windowActivation:
            if event.source != cthulhu_state.activeWindow:
                return True, "Window activation"
            else:
                return False, "Window activation for already-active window"

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

        if eType.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 eType.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 _eventSourceIsDead(self, event: Atspi.Event) -> bool:
        if AXObject.is_dead(event.source):
            tokens = ["EVENT MANAGER: source of", event.type, "is dead"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return True

        return False

    def _ignoreDuringDeluge(self, event: Atspi.Event) -> bool:
        """Returns true if this event should be ignored during a deluge."""

        if self._eventSourceIsDead(event):
            return True

        ignore = ["object:text-changed:delete",
                  "object:text-changed:insert",
                  "object:text-changed:delete:system",
                  "object:text-changed:insert:system",
                  "object:text-attributes-changed",
                  "object:text-caret-moved",
                  "object:children-changed:add",
                  "object:children-changed:add:system",
                  "object:children-changed:remove",
                  "object:children-changed:remove:system",
                  "object:property-change:accessible-name",
                  "object:property-change:accessible-description",
                  "object:selection-changed",
                  "object:state-changed:showing",
                  "object:state-changed:sensitive"]

        if event.type not in ignore:
            return False

        return event.source != cthulhu_state.locusOfFocus

    def _inDeluge(self) -> bool:
        size = self._eventQueue.qsize()
        if size > 100:
            msg = f"EVENT MANAGER: DELUGE! Queue size is {size}"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        return False

    def _processDuringFlood(self, event: Atspi.Event) -> bool:
        """Returns true if this event should be processed during a flood."""

        if self._eventSourceIsDead(event):
            return False

        ignore = ["object:text-changed:delete",
                  "object:text-changed:insert",
                  "object:text-changed:delete:system",
                  "object:text-changed:insert:system",
                  "object:text-attributes-changed",
                  "object:text-caret-moved",
                  "object:children-changed:add",
                  "object:children-changed:add:system",
                  "object:children-changed:remove",
                  "object:children-changed:remove:system",
                  "object:property-change:accessible-name",
                  "object:property-change:accessible-description",
                  "object:selection-changed",
                  "object:state-changed:showing",
                  "object:state-changed:sensitive"]

        if event.type not in ignore:
            return True

        return event.source == cthulhu_state.locusOfFocus

    def _prioritizeDuringFlood(self, event: Atspi.Event) -> bool:
        """Returns true if this event should be prioritized during a flood."""

        if event.type.startswith("object:state-changed:focused"):
            return event.detail1

        if event.type.startswith("object:state-changed:selected"):
            return event.detail1

        if event.type.startswith("object:text-selection-changed"):
            return True

        if event.type.startswith("window:activate"):
            return True

        if event.type.startswith("window:deactivate"):
            return True

        if event.type.startswith("object:state-changed:active"):
            return AXUtilities.is_frame(event.source) or AXUtilities.is_window(event.source)

        if event.type.startswith("document:load-complete"):
            return True

        if event.type.startswith("object:state-changed:busy"):
            return True

        return False

    def _pruneEventsDuringFlood(self) -> None:
        """Gets rid of events we don't care about during a flood."""

        oldSize = self._eventQueue.qsize()

        newQueue: queue.Queue[Any] = queue.Queue(0)
        while not self._eventQueue.empty():
            try:
                event = self._eventQueue.get()
            except Exception:
                continue

            if self._processDuringFlood(event):
                newQueue.put(event)
                self._queuePrintln(event, isPrune=False)
            self._eventQueue.task_done()

        self._eventQueue = newQueue
        newSize = self._eventQueue.qsize()

        msg = f"EVENT MANAGER: {oldSize - newSize} events pruned. New size: {newSize}"
        debug.printMessage(debug.LEVEL_INFO, msg, True)

    def _context_token_for_accessible(self, accessible: Any) -> Optional[str]:
        if accessible is None:
            return None

        application = AXObject.get_application(accessible)
        if application is None:
            return None

        pid = AXObject.get_process_id(application)
        name = (AXObject.get_name(accessible) or "").strip()
        if not name:
            name = AXObject.get_role_name(accessible) or AXObject.get_accessible_id(accessible) or "unknown"
        if pid < 0 and not name:
            return None

        return f"{max(pid, 0)}:{name}"

    def _context_token_for_event(self, event: Any) -> Optional[str]:
        return self._context_token_for_accessible(getattr(event, "source", None))

    def _event_is_from_stale_context(self, event: Any) -> bool:
        prioritizedContextToken = self._prioritizedContextToken or cthulhu_state.prioritizedDesktopContextToken
        if not prioritizedContextToken:
            return False

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

        eventToken = self._context_token_for_event(event)
        if eventToken is None:
            return False

        return eventToken != prioritizedContextToken

    def _is_obsolete_by_context(self, event: Any) -> bool:
        if not (self._churnSuppressed or cthulhu_state.pauseAtspiChurn):
            return False

        return self._event_is_from_stale_context(event)

    def _should_preserve_during_suppression(self, event: Any) -> bool:
        eventType = getattr(event, "type", "")
        if eventType.startswith("window:"):
            return True

        if eventType.startswith("object:state-changed:focused") and getattr(event, "detail1", 0):
            return True

        if eventType.startswith("object:state-changed:active"):
            return AXUtilities.is_frame(event.source) or AXUtilities.is_window(event.source)

        if eventType.startswith("object:text-selection-changed"):
            return True

        if eventType.startswith("object:selection-changed"):
            return self._context_token_for_event(event) == (self._prioritizedContextToken or cthulhu_state.prioritizedDesktopContextToken)

        return False

    def _flush_stale_atspi_events(self) -> None:
        """Drops queued events that no longer match the compositor context."""

        self._gidleLock.acquire()
        try:
            originalQueue = self._eventQueue
            newQueue: queue.Queue[Any] = queue.Queue(0)
            while not originalQueue.empty():
                try:
                    event = originalQueue.get_nowait()
                except queue.Empty:
                    break

                if self._event_is_from_stale_context(event) and not self._should_preserve_during_suppression(event):
                    continue

                newQueue.put(event)

            self._eventQueue = newQueue
            if self._asyncMode and not self._eventQueue.empty() and not self._gidleId:
                self._gidleId = GLib.idle_add(self._dequeue)
        finally:
            self._gidleLock.release()

    def _inFlood(self) -> bool:
        size = self._eventQueue.qsize()
        if size > 50:
            msg = f"EVENT MANAGER: FLOOD? Queue size is {size}"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        return False

    def _processObjectEvent(self, event: Atspi.Event) -> None:
        """Handles all object events destined for scripts.

        Arguments:
        - e: an at-spi event.
        """

        debug.printObjectEvent(debug.LEVEL_INFO, event, timestamp=True)
        eType = event.type

        if eType.startswith("object:children-changed:remove") \
           and event.source == AXUtilities.get_desktop():
            cthulhu.cthulhuApp.scriptManager.reclaim_scripts()
            return

        if eType.startswith("window:") and not eType.endswith("create"):
            cthulhu.cthulhuApp.scriptManager.reclaim_scripts()
        elif eType.startswith("object:state-changed:active") \
           and AXUtilities.is_frame(event.source):
            cthulhu.cthulhuApp.scriptManager.reclaim_scripts()

        if AXObject.is_dead(event.source) or AXUtilities.is_defunct(event.source):
            tokens = ["EVENT MANAGER: Ignoring defunct object:", event.source]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)

            if eType.startswith("window:deactivate") or eType.startswith("window:destroy") \
               and cthulhu_state.activeWindow == event.source:
                msg = 'EVENT MANAGER: Clearing active window, script, and locus of focus'
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                cthulhu_state.locusOfFocus = None
                cthulhu_state.activeWindow = None
                cthulhu.cthulhuApp.scriptManager.set_active_script(None, "focus: active-window-dead")
            return

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

        if self._inFlood():
            if not self._processDuringFlood(event):
                msg = 'EVENT MANAGER: Not processing this event due to flood.'
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return
            if self._prioritizeDuringFlood(event):
                msg = 'EVENT MANAGER: Pruning event queue due to flood.'
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                self._pruneEventsDuringFlood()

        if eType.startswith('object:selection-changed') \
           and event.source in self._parentsOfDefunctDescendants:
            msg = 'EVENT MANAGER: Ignoring event from parent of defunct descendants'
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return

        if not debug.eventDebugFilter or debug.eventDebugFilter.match(eType) \
           and not eType.startswith("mouse:"):
            indent = " " * 32
            debug.printDetails(debug.LEVEL_INFO, indent, event.source)
            if isinstance(event.any_data, Atspi.Accessible):
                debug.printMessage(debug.LEVEL_INFO, f"{indent}ANY DATA:")
                debug.printDetails(debug.LEVEL_INFO, indent, event.any_data, includeApp=False)

        script = self._get_scriptForEvent(event)
        if not script:
            msg = "ERROR: Could not get script for event"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return

        setNewActiveScript, reason = self._isActivatableEvent(event, script)
        debug.print_log(debug.LEVEL_INFO, "EVENT MANAGER",
                        f"Change active script: {setNewActiveScript}", reason, True)

        if setNewActiveScript:
            try:
                cthulhu.cthulhuApp.scriptManager.set_active_script(script, reason)
            except Exception as error:
                tokens = ["EVENT MANAGER: Exception setting active script for",
                          event.source, ":", error]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                return

        try:
            script.processObjectEvent(event)
        except Exception as error:
            msg = f"EVENT MANAGER: Exception processing {event.type}: {error}"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            debug.printException(debug.LEVEL_INFO)
        else:
            if self._compositorStateAdapter is not None:
                if eType.startswith("window:activate") or (
                    eType.startswith("object:state-changed:focused") and event.detail1
                ):
                    self._compositorStateAdapter.sync_accessible_context(event.type)

        tokens = ["EVENT MANAGER: locusOfFocus:", cthulhu_state.locusOfFocus,
                  "activeScript:", cthulhu_state.activeScript]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

        if debug.LEVEL_INFO >= debug.debugLevel and cthulhu_state.activeScript:
            attributes = cthulhu_state.activeScript.getTransferableAttributes()
            for key, value in attributes.items():
                msg = f"EVENT MANAGER: {key}: {value}"
                debug.printMessage(debug.LEVEL_INFO, msg, True)

    def processBrailleEvent(self, brailleEvent: input_event.BrailleEvent) -> bool:
        """Called whenever a cursor key is pressed on the Braille display.

        Arguments:
        - brailleEvent: an instance of input_event.BrailleEvent

        Returns True if the command was consumed; otherwise False
        """

        if cthulhu_state.activeScript \
           and cthulhu_state.activeScript.consumesBrailleEvent(brailleEvent):
            self._enqueue(brailleEvent)
            return True
        else:
            return False

_manager: Optional[EventManager] = None

def getManager() -> Optional[EventManager]:
    global _manager
    if _manager is None:
        try:
            if cthulhu.cthulhuApp:
                _manager = cthulhu.cthulhuApp.eventManager
        except AttributeError:
            pass
    return _manager
