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

# pylint: disable=wrong-import-position
# pylint: disable=too-many-public-methods
# pylint: disable=too-many-lines

"""Provides utilities for managing input events."""

# This has to be the first non-docstring line in the module to make linters happy.
from __future__ import annotations

__id__        = "$Id$"
__version__   = "$Revision$"
__date__      = "$Date$"
__copyright__ = "Copyright (c) 2024 Igalia, S.L." \
                "Copyright (c) 2024 GNOME Foundation Inc."
__license__   = "LGPL"

import os
from typing import TYPE_CHECKING, Optional, Union, Tuple, List, Dict, Any

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

from . import debug
from . import focus_manager
from . import input_event
from . import script_manager
from . import settings
from . import cthulhu_state
from .wnck_support import load_wnck
from .ax_object import AXObject
from .ax_utilities import AXUtilities
from .ax_utilities_application import AXUtilitiesApplication

if TYPE_CHECKING:
    from . import keybindings

class InputEventManager:
    """Provides utilities for managing input events."""

    def __init__(self) -> None:
        self._last_input_event: Optional[input_event.InputEvent] = None
        self._last_non_modifier_key_event: Optional[input_event.KeyboardEvent] = None
        self._device: Optional[Atspi.Device] = None
        self._pointer_moved_id: int = 0
        self._mapped_keycodes: List[int] = []
        self._mapped_keysyms: List[int] = []
        self._grabbed_bindings: Dict[int, keybindings.KeyBinding] = {}
        self._paused: bool = False
        self._wnck = None
        self._did_attempt_wnck_load: bool = False
        self._scriptWithSuspendedGrabsForXterm = None

    def activate_device(self) -> Atspi.Device:
        """Creates and returns the AT-SPI device used by this manager."""

        if self._device is not None:
            return self._device

        if Atspi.get_version() >= (2, 55, 90):
            self._device = Atspi.Device.new_full("org.stormux.Cthulhu")
        else:
            self._device = Atspi.Device.new()

        return self._device

    def get_device(self) -> Optional[Atspi.Device]:
        """Returns the active AT-SPI device, if any."""

        return self._device

    def start_key_watcher(self) -> None:
        """Starts the watcher for keyboard input events."""

        msg = "INPUT EVENT MANAGER: Starting key watcher."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        self.activate_device().add_key_watcher(self.process_keyboard_event)

    def stop_key_watcher(self) -> None:
        """Starts the watcher for keyboard input events."""

        msg = "INPUT EVENT MANAGER: Stopping key watcher."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        self._device = None

    def enable_pointer_monitoring(self) -> bool:
        """Enables pointer monitoring on the current device, if possible."""

        device = self.get_device()
        if device is None:
            return False

        deviceCapability = getattr(Atspi, "DeviceCapability", None)
        if deviceCapability is None:
            return False

        pointerMonitor = getattr(deviceCapability, "POINTER_MONITOR", None)
        if pointerMonitor is None:
            return False

        setCapabilities = getattr(device, "set_capabilities", None)
        if not callable(setCapabilities):
            return False

        currentCapabilities = 0
        getCapabilities = getattr(device, "get_capabilities", None)
        if callable(getCapabilities):
            currentCapabilities = getCapabilities()

        try:
            grantedCapabilities = setCapabilities(currentCapabilities | pointerMonitor)
        except GLib.GError:
            return False

        if isinstance(grantedCapabilities, bool):
            return grantedCapabilities

        try:
            return bool(int(grantedCapabilities) & int(pointerMonitor))
        except (TypeError, ValueError):
            return False

    def start_pointer_watcher(self, callback) -> None:
        """Starts the watcher for pointer movement events."""

        device = self.get_device()
        if device is None:
            return

        self._pointer_moved_id = device.connect("pointer-moved", callback)

    def stop_pointer_watcher(self) -> None:
        """Stops the watcher for pointer movement events."""

        device = self.get_device()
        if device is None or not self._pointer_moved_id:
            self._pointer_moved_id = 0
            return

        device.disconnect(self._pointer_moved_id)
        self._pointer_moved_id = 0

    def pause_key_watcher(self, pause: bool = True, reason: str = "") -> None:
        """Pauses processing of keyboard input events."""

        msg = f"INPUT EVENT MANAGER: Pause queueing: {pause}. {reason}"
        debug.print_message(debug.LEVEL_INFO, msg, True)
        self._paused = pause

    def check_grabbed_bindings(self) -> None:
        """Checks the grabbed key bindings."""

        msg = f"INPUT EVENT MANAGER: {len(self._grabbed_bindings)} grabbed key bindings."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        for grab_id, binding in self._grabbed_bindings.items():
            msg = f"INPUT EVENT MANAGER: {grab_id} for: {binding}"
            debug.print_message(debug.LEVEL_INFO, msg, True)

    def add_grabs_for_keybinding(self, binding: keybindings.KeyBinding) -> List[int]:
        """Adds grabs for binding if it is enabled, returns grab IDs."""

        if not (binding.is_enabled() and binding.is_bound()):
            return []

        if binding.has_grabs():
            tokens = ["INPUT EVENT MANAGER:", binding, "already has grabs."]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return []

        if self._device is None:
            tokens = ["INPUT EVENT MANAGER: No device to add grab for", binding]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return []

        grab_ids = []
        for kd in binding.key_definitions():
            grab_id = self._device.add_key_grab(kd, None)
            if grab_id == 0:
                continue
            grab_ids.append(grab_id)
            self._grabbed_bindings[grab_id] = binding

        return grab_ids

    def remove_grabs_for_keybinding(self, binding: keybindings.KeyBinding) -> None:
        """Removes grabs for binding."""

        if self._device is None:
            tokens = ["INPUT EVENT MANAGER: No device to remove grab from", binding]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return

        grab_ids = binding.get_grab_ids()
        if not grab_ids:
            tokens = ["INPUT EVENT MANAGER:", binding, "doesn't have grabs to remove."]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return

        for grab_id in grab_ids:
            self._device.remove_key_grab(grab_id)
            removed = self._grabbed_bindings.pop(grab_id, None)
            if removed is None:
                msg = f"INPUT EVENT MANAGER: No key binding for grab id {grab_id}"
                debug.print_message(debug.LEVEL_INFO, msg, True)

    def remove_grab_by_id(self, grab_id: int) -> None:
        """Removes a grab by id."""

        if self._device is None:
            msg = f"INPUT EVENT MANAGER: No device to remove grab id {grab_id}"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return

        self._device.remove_key_grab(grab_id)
        binding = self._grabbed_bindings.pop(grab_id, None)
        if binding and grab_id in binding._grab_ids:
            binding._grab_ids.remove(grab_id)
        if binding:
            tokens = ["INPUT EVENT MANAGER: Removed grab", grab_id, "for", binding]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)

    def map_keysym_to_modifier(self, keysym: int) -> int:
        """Maps keysym as a modifier, returns the newly-mapped modifier."""

        if self._device is None:
            msg = f"INPUT EVENT MANAGER: No device to map keysym {keysym} to modifier"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return 0

        self._mapped_keysyms.append(keysym)
        return self._device.map_keysym_modifier(keysym)

    def map_keycode_to_modifier(self, keycode: int) -> int:
        """Maps keycode as a modifier, returns the newly-mapped modifier."""

        if self._device is None:
            msg = f"INPUT EVENT MANAGER: No device to map keycode {keycode} to modifier"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return 0

        self._mapped_keycodes.append(keycode)
        return self._device.map_modifier(keycode)

    def unmap_all_modifiers(self) -> None:
        """Unmaps all previously mapped modifiers."""

        if self._device is None:
            msg = "INPUT EVENT MANAGER: No device to unmap all modifiers from"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return

        for keycode in self._mapped_keycodes:
            self._device.unmap_modifier(keycode)

        for keysym in self._mapped_keysyms:
            self._device.unmap_keysym_modifier(keysym)

        self._mapped_keycodes.clear()
        self._mapped_keysyms.clear()

    def add_grab_for_modifier(self, modifier: str, keysym: int, keycode: int) -> int:
        """Adds grab for modifier, returns grab id."""

        if self._device is None:
            msg = f"INPUT EVENT MANAGER: No device to add grab for {modifier}"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return -1

        kd = Atspi.KeyDefinition()
        kd.keysym = keysym
        kd.keycode = keycode
        kd.modifiers = 0
        grab_id = self._device.add_key_grab(kd)

        msg = f"INPUT EVENT MANAGER: Grab id for {modifier}: {grab_id}"
        debug.print_message(debug.LEVEL_INFO, msg, True)
        return grab_id

    def remove_grab_for_modifier(self, modifier: str, grab_id: int) -> None:
        """Removes grab for modifier."""

        if self._device is None:
            msg = f"INPUT EVENT MANAGER: No device to remove grab from {modifier}"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return

        self._device.remove_key_grab(grab_id)
        msg = f"INPUT EVENT MANAGER: Grab id removed for {modifier}: {grab_id}"
        debug.print_message(debug.LEVEL_INFO, msg, True)

    def grab_keyboard(self, reason: str = "") -> None:
        """Grabs the keyboard, e.g. when entering learn mode."""

        msg = "INPUT EVENT MANAGER: Grabbing keyboard"
        if reason:
            msg += f" Reason: {reason}"
        debug.print_message(debug.LEVEL_INFO, msg, True)
        Atspi.Device.grab_keyboard(self._device)

    def ungrab_keyboard(self, reason: str = "") -> None:
        """Removes keyboard grab, e.g. when exiting learn mode."""

        msg = "INPUT EVENT MANAGER: Ungrabbing keyboard"
        if reason:
            msg += f" Reason: {reason}"
        debug.print_message(debug.LEVEL_INFO, msg, True)
        Atspi.Device.ungrab_keyboard(self._device)

    def process_braille_event(self, event: Atspi.Event) -> bool:
        """Processes this Braille event."""

        braille_event = input_event.BrailleEvent(event)
        result = braille_event.process()
        self._last_input_event = braille_event
        self._last_non_modifier_key_event = None
        return result

    def process_mouse_button_event(self, event: Atspi.Event) -> None:
        """Processes this Mouse event."""

        mouse_event = input_event.MouseButtonEvent(event)
        mouse_event.set_click_count(self._determine_mouse_event_click_count(mouse_event))
        self._last_input_event = mouse_event

    def process_remote_controller_event(self, event: input_event.RemoteControllerEvent) -> None:
        """Processes this RemoteController event."""

        # TODO - JD: It probably makes sense to process remote controller events here rather
        # than just updating state.
        self._last_input_event = event
        self._last_non_modifier_key_event = None

    @staticmethod
    def _get_top_level_window(obj: Optional[Atspi.Accessible]) -> Optional[Atspi.Accessible]:
        """Returns the top-level window containing obj, if one can be found."""

        if obj is None:
            return None

        if AXUtilities.is_frame(obj) or AXUtilities.is_window(obj) or AXUtilities.is_dialog_or_alert(obj):
            return obj

        return AXObject.find_ancestor(
            obj,
            lambda x: AXUtilities.is_frame(x)
            or AXUtilities.is_window(x)
            or AXUtilities.is_dialog_or_alert(x),
        )

    def _get_wnck(self):
        """Returns Wnck when available for X11 active-window checks."""

        if not self._did_attempt_wnck_load:
            self._did_attempt_wnck_load = True
            try:
                self._wnck = load_wnck()
            except Exception as error:
                msg = f"INPUT EVENT MANAGER: Wnck unavailable for active-window check: {error}"
                debug.print_message(debug.LEVEL_INFO, msg, True)
                self._wnck = None

        return self._wnck

    def _get_active_x11_window(self) -> Optional[Any]:
        """Returns the active X11 window if Wnck can provide it."""

        wnck = self._get_wnck()
        if wnck is None:
            return None

        try:
            screen = wnck.Screen.get_default()
            if screen is None:
                return None
            screen.force_update()
            return screen.get_active_window()
        except Exception as error:
            msg = f"INPUT EVENT MANAGER: Could not obtain active X11 window: {error}"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return None

    def _get_active_x11_window_pid(self) -> int:
        """Returns the PID of the active X11 window if Wnck can provide it."""

        window = self._get_active_x11_window()
        if window is None:
            return -1

        try:
            return int(window.get_pid())
        except Exception as error:
            msg = f"INPUT EVENT MANAGER: Could not obtain active X11 window PID: {error}"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return -1

    @staticmethod
    def _identifier_is_xterm(value: Any) -> bool:
        """Returns True if value identifies XTerm."""

        if not isinstance(value, str):
            return False

        identifier = os.path.basename(value.strip().lower())
        return identifier == "xterm"

    @staticmethod
    def _safe_call(window: Any, attrName: str) -> Optional[Any]:
        """Returns attrName() for window, or None when unavailable."""

        attr = getattr(window, attrName, None)
        if not callable(attr):
            return None

        try:
            return attr()
        except Exception:
            return None

    def _log_active_x11_window_for_xterm_check(self, window: Any) -> None:
        """Logs X11 window details used for XTerm pass-through decisions."""

        classGroup = self._safe_call(window, "get_class_group")
        classGroupName = None
        classGroupResClass = None
        if classGroup is not None:
            classGroupName = self._safe_call(classGroup, "get_name")
            classGroupResClass = self._safe_call(classGroup, "get_res_class")

        tokens = [
            "INPUT EVENT MANAGER: Active X11 window for XTerm check:",
            "name",
            self._safe_call(window, "get_name"),
            "class group",
            self._safe_call(window, "get_class_group_name") or classGroupName,
            "class group res class",
            classGroupResClass,
            "class instance",
            self._safe_call(window, "get_class_instance_name"),
            "pid",
            self._safe_call(window, "get_pid"),
        ]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

    def _active_x11_window_xterm_match(self) -> Optional[bool]:
        """Returns whether the active X11 window is XTerm, or None when unknown."""

        window = self._get_active_x11_window()
        if window is None:
            msg = "INPUT EVENT MANAGER: XTerm matcher cannot identify active X11 window."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return None

        self._log_active_x11_window_for_xterm_check(window)
        for attrName in ("get_class_group_name", "get_class_instance_name", "get_name"):
            if self._identifier_is_xterm(self._safe_call(window, attrName)):
                return True

        classGroup = self._safe_call(window, "get_class_group")
        if classGroup is not None:

            for attrName in ("get_name", "get_res_class"):
                if self._identifier_is_xterm(self._safe_call(classGroup, attrName)):
                    return True

        try:
            pid = int(window.get_pid())
        except Exception:
            pid = -1
        if pid < 1:
            return False

        try:
            with open(f"/proc/{pid}/cmdline", "rb") as cmdlineFile:
                executable = cmdlineFile.read().split(b"\0", 1)[0].decode(errors="ignore")
        except OSError:
            return False

        return self._identifier_is_xterm(executable)

    def _active_x11_window_is_xterm(self) -> bool:
        """Returns True when the active X11 window appears to be XTerm."""

        return self._active_x11_window_xterm_match() is True

    def _find_active_x11_atspi_window(self) -> Optional[Atspi.Accessible]:
        """Returns the focused AT-SPI window for the active X11 PID, if possible."""

        x11Pid = self._get_active_x11_window_pid()
        if x11Pid < 1:
            return None

        app = AXUtilitiesApplication.get_application_with_pid(x11Pid)
        if app is None:
            return None

        candidates = [
            child for child in AXObject.iter_children(app)
            if AXUtilities.is_frame(child)
            or AXUtilities.is_window(child)
            or AXUtilities.is_dialog_or_alert(child)
        ]
        if not candidates:
            tokens = ["INPUT EVENT MANAGER: No AT-SPI windows found for active X11 pid", x11Pid]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return None

        focusedCandidates = []
        for window in candidates:
            if AXUtilities.is_focused(window) or AXUtilities.get_focused_object(window) is not None:
                focusedCandidates.append(window)

        if len(focusedCandidates) == 1:
            tokens = [
                "INPUT EVENT MANAGER: Recovered active AT-SPI window from X11 pid",
                x11Pid,
                focusedCandidates[0],
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return focusedCandidates[0]

        if focusedCandidates:
            tokens = [
                "INPUT EVENT MANAGER: Multiple focused AT-SPI windows for active X11 pid",
                x11Pid,
                focusedCandidates,
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return focusedCandidates[0]

        tokens = [
            "INPUT EVENT MANAGER: Active X11 pid",
            x11Pid,
            "has AT-SPI windows but none report focus",
            candidates,
        ]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        return None

    def _active_x11_window_differs_from(self, obj: Optional[Atspi.Accessible]) -> bool:
        """Returns True when X11 focus is known to be outside the AT-SPI context."""

        if obj is None:
            return False

        x11Pid = self._get_active_x11_window_pid()
        if x11Pid < 1:
            return False

        axPid = AXObject.get_process_id(obj)
        if axPid < 1:
            return False

        result = x11Pid != axPid
        if result:
            tokens = [
                "INPUT EVENT MANAGER: Active X11 window PID",
                x11Pid,
                "differs from AT-SPI active window PID",
                axPid,
                "for",
                obj,
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        return result

    @staticmethod
    def _get_active_script_app() -> Optional[Atspi.Accessible]:
        """Returns the app for the active script, if one is available."""

        script = script_manager.get_manager().get_active_script()
        if script is None:
            return None

        return getattr(script, "app", None)

    @staticmethod
    def _lacks_atspi_context(
        window: Optional[Atspi.Accessible],
        focus: Optional[Atspi.Accessible],
        pendingFocus: Optional[Atspi.Accessible],
    ) -> bool:
        """Returns True when Cthulhu has no meaningful AT-SPI input context."""

        if window is not None or focus is not None or pendingFocus is not None:
            return False

        return True

    def _should_pass_through_for_active_xterm(
        self,
        window: Optional[Atspi.Accessible],
        focus: Optional[Atspi.Accessible],
        pendingFocus: Optional[Atspi.Accessible],
    ) -> bool:
        """Returns True when XTerm is active and Cthulhu lacks matching AT-SPI context."""

        if pendingFocus is not None:
            msg = "INPUT EVENT MANAGER: XTerm matcher false; pending focus exists."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False
        match = self._active_x11_window_xterm_match()
        tokens = ["INPUT EVENT MANAGER: XTerm matcher returned", match]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        if match is True:
            return True
        if match is None and self._scriptWithSuspendedGrabsForXterm is not None:
            msg = (
                "INPUT EVENT MANAGER: Keeping XTerm key-grab suspension; "
                "active X11 window is temporarily unknown."
            )
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return True
        return False

    def _suspend_key_grabs_for_xterm(self) -> None:
        """Suspends active-script key grabs so XTerm/Fenrir can receive them."""

        script = script_manager.get_manager().get_active_script()
        if script is None or script is self._scriptWithSuspendedGrabsForXterm:
            return

        if self._scriptWithSuspendedGrabsForXterm is not None:
            self._restore_key_grabs_after_xterm()

        removeGrabs = getattr(script, "removeKeyGrabs", None)
        if not callable(removeGrabs):
            return

        tokens = [
            "INPUT EVENT MANAGER: Removing active script key grabs while XTerm is focused:",
            script,
        ]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        removeGrabs()
        self._scriptWithSuspendedGrabsForXterm = script

    def _restore_key_grabs_after_xterm(self) -> None:
        """Restores key grabs after leaving XTerm."""

        script = self._scriptWithSuspendedGrabsForXterm
        self._scriptWithSuspendedGrabsForXterm = None
        if script is None:
            return

        activeScript = script_manager.get_manager().get_active_script()
        if script is not activeScript:
            tokens = [
                "INPUT EVENT MANAGER: Not restoring XTerm-suspended key grabs; active script changed:",
                script,
                activeScript,
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return

        addGrabs = getattr(script, "addKeyGrabs", None)
        if not callable(addGrabs):
            return

        tokens = [
            "INPUT EVENT MANAGER: Restoring active script key grabs after leaving XTerm:",
            script,
        ]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        addGrabs()

    # pylint: disable=too-many-arguments
    # pylint: disable=too-many-positional-arguments
    def process_keyboard_event(
        self,
        _device: Atspi.Device,
        pressed: bool,
        keycode: int,
        keysym: int,
        modifiers: int,
        text: str
    ) -> bool:
        """Processes this Atspi keyboard event."""

        if self._paused:
            msg = "INPUT EVENT MANAGER: Keyboard event processing is paused."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False
        if cthulhu_state.capturingKeys:
            msg = "INPUT EVENT MANAGER: Capturing keys; ignoring keyboard event."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False

        event = input_event.KeyboardEvent(pressed, keycode, keysym, modifiers, text)
        if event in [self._last_input_event, self._last_non_modifier_key_event]:
            msg = "INPUT EVENT MANAGER: Received duplicate event."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False

        manager = focus_manager.get_manager()
        pendingFocus = cthulhu_state.pendingSelfHostedFocus
        if pendingFocus is not None:
            tokens = ["INPUT EVENT MANAGER: Using pending self-hosted focus for keyboard event:", pendingFocus]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        window = manager.get_active_window()
        focus = manager.get_locus_of_focus()
        if self._should_pass_through_for_active_xterm(window, focus, pendingFocus):
            msg = "INPUT EVENT MANAGER: Passing through keyboard event; active XTerm is focused."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            self._suspend_key_grabs_for_xterm()
            return False
        self._restore_key_grabs_after_xterm()
        if pressed:
            if not AXUtilities.can_be_active_window(window, clear_cache=True):
                new_window = AXUtilities.find_active_window()
                if new_window is None:
                    new_window = self._find_active_x11_atspi_window()
                if new_window is not None:
                    window = new_window
                    tokens = ["INPUT EVENT MANAGER: Updating window and active window to", window]
                    debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                    manager.set_active_window(window)
                else:
                    focus_window = self._get_top_level_window(pendingFocus or manager.get_locus_of_focus())
                    if focus_window is not None:
                        window = focus_window
                        tokens = [
                            "INPUT EVENT MANAGER: Recovering active window from locus of focus:",
                            window,
                        ]
                        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                        manager.set_active_window(window)
                    else:
                        # One example: Brave's popup menus live in frames which lack the active
                        # state. Failing to revalidate the window on a key press is inconclusive;
                        # do not wipe out the last known window and focus state.
                        focus = pendingFocus or manager.get_locus_of_focus()
                        staleContext = window or self._get_active_script_app()
                        if self._active_x11_window_differs_from(staleContext):
                            msg = (
                                "INPUT EVENT MANAGER: X11 focus moved to an untracked window; "
                                "preserving Cthulhu key handling."
                            )
                            debug.print_message(debug.LEVEL_INFO, msg, True)

                        tokens = [
                            "WARNING:",
                            window,
                            "cannot be confirmed as active. No alternative found; preserving existing context.",
                        ]
                        debug.print_tokens(debug.LEVEL_WARNING, tokens, True)
            event.set_window(window)
            event.set_object(pendingFocus or manager.get_locus_of_focus())
            event.set_script(script_manager.get_manager().get_active_script())
        elif self.last_event_was_keyboard():
            assert isinstance(self._last_input_event, input_event.KeyboardEvent)
            event.set_window(self._last_input_event.get_window())
            event.set_object(self._last_input_event.get_object())
            event.set_script(self._last_input_event.get_script())
        else:
            event.set_window(manager.get_active_window())
            event.set_object(pendingFocus or manager.get_locus_of_focus())
            event.set_script(script_manager.get_manager().get_active_script())

        event._finalize_initialization()
        event.set_click_count(self._determine_keyboard_event_click_count(event))
        event.process()

        if event.is_modifier_key():
            if self.is_release_for(event, self._last_input_event):
                msg = "INPUT EVENT MANAGER: Clearing last non modifier key event"
                debug.print_message(debug.LEVEL_INFO, msg, True)
                self._last_non_modifier_key_event = None
        else:
            self._last_non_modifier_key_event = event
        self._last_input_event = event
        return True

    # pylint: enable=too-many-arguments
    # pylint: enable=too-many-positional-arguments

    def _determine_keyboard_event_click_count(self, event: input_event.KeyboardEvent) -> int:
        """Determines the click count of event."""

        if not self.last_event_was_keyboard():
            return 1

        if event.is_modifier_key():
            last_event = self._last_input_event
        else:
            last_event = self._last_non_modifier_key_event or self._last_input_event

        assert isinstance(last_event, input_event.KeyboardEvent)
        if (event.time - last_event.time > settings.doubleClickTimeout) or \
           (event.keyval_name != last_event.keyval_name) or \
           (event.get_object() != last_event.get_object()):
            return 1

        last_count = last_event.get_click_count()
        if not event.is_pressed_key():
            return last_count
        if last_event.is_pressed_key():
            return last_count
        if (event.is_modifier_key() and last_count == 2) or last_count == 3:
            return 1
        return last_count + 1

    def _determine_mouse_event_click_count(self, event: input_event.MouseButtonEvent) -> int:
        """Determines the click count of event."""

        if not self.last_event_was_mouse_button():
            return 1

        assert isinstance(self._last_input_event, input_event.MouseButtonEvent)
        if not event.pressed:
            return self._last_input_event.get_click_count()
        if self._last_input_event.button != event.button:
            return 1
        if event.time - self._last_input_event.time > settings.doubleClickTimeout:
            return 1

        return self._last_input_event.get_click_count() + 1

    def last_event_was_keyboard(self) -> bool:
        """Returns True if the last event is a keyboard event."""

        return isinstance(self._last_input_event, input_event.KeyboardEvent)

    def last_event_was_mouse_button(self) -> bool:
        """Returns True if the last event is a mouse button event."""

        return isinstance(self._last_input_event, input_event.MouseButtonEvent)

    def is_release_for(self, event1: Optional[input_event.InputEvent], event2: Optional[input_event.InputEvent]) -> bool:
        """Returns True if event1 is a release for event2."""

        if event1 is None or event2 is None:
            return False

        if not isinstance(event1, input_event.KeyboardEvent) \
           or not isinstance(event2, input_event.KeyboardEvent):
            return False

        if event1.is_pressed_key() or not event2.is_pressed_key():
            return False

        result = event1.id == event2.id \
            and event1.hw_code == event2.hw_code \
            and event1.keyval_name == event2.keyval_name

        if result and not event1.is_modifier_key():
            result = event1.modifiers == event2.modifiers

        msg = (
            f"INPUT EVENT MANAGER: {event1.as_single_line_string()} "
            f"is release for {event2.as_single_line_string()}: {result}"
        )
        debug.print_message(debug.LEVEL_INFO, msg, True)
        return result

    def last_event_equals_or_is_release_for_event(self, event: Optional[input_event.InputEvent]) -> bool:
        """Returns True if the last event equals the provided event, or is the release for it."""

        if self._last_input_event is event:
            return True

        if not self.last_event_was_keyboard():
            return False

        if self._last_non_modifier_key_event is None:
            return False

        if event == self._last_non_modifier_key_event:
            return True

        return self.is_release_for(self._last_non_modifier_key_event, event)

    def _last_key_and_modifiers(self) -> Tuple[str, int]:
        """Returns the last keyval name and modifiers"""

        if self._last_non_modifier_key_event is None:
            return "", 0

        if not self.last_event_was_keyboard():
            return "", 0

        if self._last_input_event is None:
            return self._last_non_modifier_key_event.keyval_name, 0

        return self._last_non_modifier_key_event.keyval_name, self._last_input_event.modifiers

    def last_event_was_command(self) -> bool:
        """Returns True if the last event is believed to be a command."""

        if bool(self._last_key_and_modifiers()[1] & 1 << Atspi.ModifierType.CONTROL):
            msg = "INPUT EVENT MANAGER: Last event was command."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return True

        return False

    def last_event_was_shortcut_for(self, obj: Atspi.Accessible) -> bool:
        """Returns True if the last event is believed to be a shortcut key for obj."""

        string = self._last_key_and_modifiers()[0]
        if not string:
            return False

        rv = False
        keys = AXObject.get_action_key_binding(obj, 0).split(";")
        for key in keys:
            if key.endswith(string.upper()):
                rv = True
                break

        if rv:
            tokens = ["INPUT EVENT MANAGER: Last event was shortcut for", obj]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        return rv

    def last_event_was_printable_key(self) -> bool:
        """Returns True if the last event is believed to be a printable key."""

        if not self.last_event_was_keyboard():
            return False

        assert isinstance(self._last_input_event, input_event.KeyboardEvent)
        if self._last_input_event.is_printable_key():
            msg = "INPUT EVENT MANAGER: Last event was printable key"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return True

        return False

    def last_event_was_caret_navigation(self) -> bool:
        """Returns True if the last event is believed to be caret navigation."""

        return self.last_event_was_character_navigation() \
            or self.last_event_was_word_navigation() \
            or self.last_event_was_line_navigation() \
            or self.last_event_was_line_boundary_navigation() \
            or self.last_event_was_file_boundary_navigation() \
            or self.last_event_was_page_navigation()

    def last_event_was_caret_selection(self) -> bool:
        """Returns True if the last event is believed to be caret selection."""

        string, mods = self._last_key_and_modifiers()
        if string not in ["Home", "End", "Up", "Down", "Left", "Right"]:
            rv = False
        else:
            rv = bool(mods & 1 << Atspi.ModifierType.SHIFT)

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was caret selection"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_backward_caret_navigation(self) -> bool:
        """Returns True if the last event is believed to be backward caret navigation."""

        string, mods = self._last_key_and_modifiers()
        if string not in ["Up", "Left"]:
            rv = False
        else:
            rv = not mods & 1 << Atspi.ModifierType.SHIFT

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was backward caret navigation"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_forward_caret_navigation(self) -> bool:
        """Returns True if the last event is believed to be forward caret navigation."""

        string, mods = self._last_key_and_modifiers()
        if string not in ["Down", "Right"]:
            rv = False
        else:
            rv = not mods & 1 << Atspi.ModifierType.SHIFT

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was forward caret navigation"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_forward_caret_selection(self) -> bool:
        """Returns True if the last event is believed to be forward caret selection."""

        string, mods = self._last_key_and_modifiers()
        if string not in ["Down", "Right"]:
            rv = False
        else:
            rv = bool(mods & 1 << Atspi.ModifierType.SHIFT)

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was forward caret selection"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_character_navigation(self) -> bool:
        """Returns True if the last event is believed to be character navigation."""

        string, mods = self._last_key_and_modifiers()
        if string not in ["Left", "Right"]:
            rv = False
        elif mods & 1 << Atspi.ModifierType.CONTROL or mods & 1 << Atspi.ModifierType.ALT:
            rv = False
        else:
            rv = True

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was character navigation"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_word_navigation(self) -> bool:
        """Returns True if the last event is believed to be word navigation."""

        string, mods = self._last_key_and_modifiers()
        if string not in ["Left", "Right"]:
            rv = False
        else:
            rv = bool(mods & 1 << Atspi.ModifierType.CONTROL)

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was word navigation"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_previous_word_navigation(self) -> bool:
        """Returns True if the last event is believed to be previous-word navigation."""

        string, mods = self._last_key_and_modifiers()
        if string != "Left":
            rv = False
        else:
            rv = bool(mods & 1 << Atspi.ModifierType.CONTROL)

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was previous-word navigation"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_next_word_navigation(self) -> bool:
        """Returns True if the last event is believed to be next-word navigation."""

        string, mods = self._last_key_and_modifiers()
        if string != "Right":
            rv = False
        else:
            rv = bool(mods & 1 << Atspi.ModifierType.CONTROL)

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was next-word navigation"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_line_navigation(self) -> bool:
        """Returns True if the last event is believed to be line navigation."""

        string, mods = self._last_key_and_modifiers()
        if string not in ["Up", "Down"]:
            rv = False
        elif mods & 1 << Atspi.ModifierType.CONTROL:
            rv = False
        else:
            focus = focus_manager.get_manager().get_locus_of_focus()
            if AXUtilities.is_single_line(focus):
                rv = False
            else:
                rv = not AXUtilities.is_widget_controlled_by_line_navigation(focus)

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was line navigation"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_paragraph_navigation(self) -> bool:
        """Returns True if the last event is believed to be paragraph navigation."""

        string, mods = self._last_key_and_modifiers()
        if not (string in ["Up", "Down"] and mods & 1 << Atspi.ModifierType.CONTROL):
            rv = False
        else:
            rv = not mods & 1 << Atspi.ModifierType.SHIFT

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was paragraph navigation"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_line_boundary_navigation(self) -> bool:
        """Returns True if the last event is believed to be navigation to start/end of line."""

        string, mods = self._last_key_and_modifiers()
        if string not in ["Home", "End"]:
            rv = False
        else:
            rv = not mods & 1 << Atspi.ModifierType.CONTROL

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was line boundary navigation"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_file_boundary_navigation(self) -> bool:
        """Returns True if the last event is believed to be navigation to top/bottom of file."""

        string, mods = self._last_key_and_modifiers()
        if string not in ["Home", "End"]:
            rv = False
        else:
            rv = bool(mods & 1 << Atspi.ModifierType.CONTROL)

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was file boundary navigation"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_page_navigation(self) -> bool:
        """Returns True if the last event is believed to be page navigation."""

        string, mods = self._last_key_and_modifiers()
        if string not in ["Page_Up", "Page_Down"]:
            rv = False
        elif mods & 1 << Atspi.ModifierType.CONTROL:
            rv = False
        else:
            focus = focus_manager.get_manager().get_locus_of_focus()
            if AXUtilities.is_single_line(focus):
                rv = False
            else:
                rv = not AXUtilities.is_widget_controlled_by_line_navigation(focus)

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was page navigation"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_page_switch(self) -> bool:
        """Returns True if the last event is believed to be a page switch."""

        string, mods = self._last_key_and_modifiers()
        if string.isnumeric():
            rv = bool(mods & 1 << Atspi.ModifierType.ALT)
        elif string in ["Page_Up", "Page_Down"]:
            rv = bool(mods & 1 << Atspi.ModifierType.CONTROL)
        else:
            rv = False

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was page switch"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_tab_navigation(self) -> bool:
        """Returns True if the last event is believed to be Tab navigation."""

        string, mods = self._last_key_and_modifiers()
        if string not in ["Tab", "ISO_Left_Tab"]:
            rv = False
        elif mods & 1 << Atspi.ModifierType.CONTROL or mods & 1 << Atspi.ModifierType.ALT:
            rv = False
        else:
            rv = True

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was Tab navigation"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_table_sort(self) -> bool:
        """Returns True if the last event is believed to be a table sort."""

        focus = focus_manager.get_manager().get_locus_of_focus()
        if not AXUtilities.is_table_header(focus):
            rv = False
        elif self.last_event_was_mouse_button():
            rv = self.last_event_was_primary_click()
        elif self.last_event_was_keyboard():
            rv = self.last_event_was_return_or_space()
        else:
            rv = False

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was table sort"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_unmodified_arrow(self) -> bool:
        """Returns True if the last event is an unmodified arrow."""

        string, mods = self._last_key_and_modifiers()
        if string not in ["Left", "Right", "Up", "Down"]:
            return False

        if mods & 1 << Atspi.ModifierType.CONTROL \
           or mods & 1 << Atspi.ModifierType.SHIFT \
           or mods & 1 << Atspi.ModifierType.ALT:
            return False

        # TODO: JD - 8 is the value of keybindings.MODIFIER_ORCA, but we need to
        # avoid a circular import.
        if mods & 1 << 8:
            return False

        return True

    def last_event_was_alt_modified(self) -> bool:
        """Returns True if the last event was alt-modified."""

        mods = self._last_key_and_modifiers()[-1]
        return bool(mods & 1 << Atspi.ModifierType.ALT)

    def last_event_was_backspace(self) -> bool:
        """Returns True if the last event is BackSpace."""

        return self._last_key_and_modifiers()[0] == "BackSpace"

    def last_event_was_down(self) -> bool:
        """Returns True if the last event is Down."""

        return self._last_key_and_modifiers()[0] == "Down"

    def last_event_was_f1(self) -> bool:
        """Returns True if the last event is F1."""

        return self._last_key_and_modifiers()[0] == "F1"

    def last_event_was_left(self) -> bool:
        """Returns True if the last event is Left."""

        return self._last_key_and_modifiers()[0] == "Left"

    def last_event_was_left_or_right(self) -> bool:
        """Returns True if the last event is Left or Right."""

        return self._last_key_and_modifiers()[0] in ["Left", "Right"]

    def last_event_was_page_up_or_page_down(self) -> bool:
        """Returns True if the last event is Page_Up or Page_Down."""

        return self._last_key_and_modifiers()[0] in ["Page_Up", "Page_Down"]

    def last_event_was_right(self) -> bool:
        """Returns True if the last event is Right."""

        return self._last_key_and_modifiers()[0] == "Right"

    def last_event_was_return(self) -> bool:
        """Returns True if the last event is Return."""

        return self._last_key_and_modifiers()[0] == "Return"

    def last_event_was_return_or_space(self) -> bool:
        """Returns True if the last event is Return or space."""

        return self._last_key_and_modifiers()[0] in ["Return", "space", " "]

    def last_event_was_return_tab_or_space(self) -> bool:
        """Returns True if the last event is Return, Tab, or space."""

        return self._last_key_and_modifiers()[0] in ["Return", "Tab", "space", " "]

    def last_event_was_space(self) -> bool:
        """Returns True if the last event is space."""

        return self._last_key_and_modifiers()[0] in [" ", "space"]

    def last_event_was_tab(self) -> bool:
        """Returns True if the last event is Tab."""

        return self._last_key_and_modifiers()[0] == "Tab"

    def last_event_was_up(self) -> bool:
        """Returns True if the last event is Up."""

        return self._last_key_and_modifiers()[0] == "Up"

    def last_event_was_up_or_down(self) -> bool:
        """Returns True if the last event is Up or Down."""

        return self._last_key_and_modifiers()[0] in ["Up", "Down"]

    def last_event_was_delete(self) -> bool:
        """Returns True if the last event is believed to be delete."""

        string, mods = self._last_key_and_modifiers()
        if string in ["Delete", "KP_Delete"]:
            rv = True
        elif string.lower() == "d":
            rv = bool(mods & 1 << Atspi.ModifierType.CONTROL)
        else:
            rv = False

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was delete"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_cut(self) -> bool:
        """Returns True if the last event is believed to be the cut command."""

        string, mods = self._last_key_and_modifiers()
        if string.lower() != "x":
            return False

        if mods & 1 << Atspi.ModifierType.CONTROL and not mods & 1 << Atspi.ModifierType.SHIFT:
            msg = "INPUT EVENT MANAGER: Last event was cut"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return True
        return False

    def last_event_was_copy(self) -> bool:
        """Returns True if the last event is believed to be the copy command."""

        string, mods = self._last_key_and_modifiers()
        if string.lower() != "c" or not mods & 1 << Atspi.ModifierType.CONTROL:
            rv = False
        elif AXUtilities.is_terminal(self._last_input_event.get_object()):
            rv = bool(mods & 1 << Atspi.ModifierType.SHIFT)
        else:
            rv = not bool(mods & 1 << Atspi.ModifierType.SHIFT)

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was copy"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_paste(self) -> bool:
        """Returns True if the last event is believed to be the paste command."""

        string, mods = self._last_key_and_modifiers()
        if string.lower() != "v" or not mods & 1 << Atspi.ModifierType.CONTROL:
            rv = False
        elif AXUtilities.is_terminal(self._last_input_event.get_object()):
            rv = bool(mods & 1 << Atspi.ModifierType.SHIFT)
        else:
            rv = not bool(mods & 1 << Atspi.ModifierType.SHIFT)

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was paste"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_undo(self) -> bool:
        """Returns True if the last event is believed to be the undo command."""

        string, mods = self._last_key_and_modifiers()
        if string.lower() != "z":
            return False
        if mods & 1 << Atspi.ModifierType.CONTROL and not mods & 1 << Atspi.ModifierType.SHIFT:
            msg = "INPUT EVENT MANAGER: Last event was undo"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return True
        return False

    def last_event_was_redo(self) -> bool:
        """Returns True if the last event is believed to be the redo command."""

        string, mods = self._last_key_and_modifiers()
        if string.lower() == "z":
            rv = bool(mods & 1 << Atspi.ModifierType.CONTROL and mods & 1 << Atspi.ModifierType.SHIFT)
        elif string.lower() == "y":
            # LibreOffice
            rv = bool(mods & 1 << Atspi.ModifierType.CONTROL \
                and not mods & 1 << Atspi.ModifierType.SHIFT)
        else:
            rv = False

        if rv:
            msg = "INPUT EVENT MANAGER: Last event was redo"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def last_event_was_select_all(self) -> bool:
        """Returns True if the last event is believed to be the select all command."""

        string, mods = self._last_key_and_modifiers()
        if string.lower() != "a":
            return False

        if (mods & 1 << Atspi.ModifierType.CONTROL and not mods & 1 << Atspi.ModifierType.SHIFT):
            msg = "INPUT EVENT MANAGER: Last event was select all"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return True
        return False

    def last_event_was_primary_click(self) -> bool:
        """Returns True if the last event is a primary mouse click."""

        if not self.last_event_was_mouse_button():
            return False

        if self._last_input_event.button == "1" and self._last_input_event.pressed:
            msg = "INPUT EVENT MANAGER: Last event was primary click"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return True
        return False

    def last_event_was_primary_release(self) -> bool:
        """Returns True if the last event is a primary mouse release."""

        if not self.last_event_was_mouse_button():
            return False

        if self._last_input_event.button == "1" and not self._last_input_event.pressed:
            msg = "INPUT EVENT MANAGER: Last event was primary release"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return True
        return False

    def last_event_was_primary_click_or_release(self) -> bool:
        """Returns True if the last event is a primary mouse click or release."""

        if not self.last_event_was_mouse_button():
            return False

        if self._last_input_event.button == "1":
            msg = "INPUT EVENT MANAGER: Last event was primary click or release"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return True
        return False

    def last_event_was_middle_click(self) -> bool:
        """Returns True if the last event is a middle mouse click."""

        if not self.last_event_was_mouse_button():
            return False

        if self._last_input_event.button == "2" and self._last_input_event.pressed:
            msg = "INPUT EVENT MANAGER: Last event was middle click"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return True
        return False

    def last_event_was_middle_release(self) -> bool:
        """Returns True if the last event is a middle mouse release."""

        if not self.last_event_was_mouse_button():
            return False

        if self._last_input_event.button == "2" and not self._last_input_event.pressed:
            msg = "INPUT EVENT MANAGER: Last event was middle release"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return True
        return False

    def last_event_was_secondary_click(self) -> bool:
        """Returns True if the last event is a secondary mouse click."""

        if not self.last_event_was_mouse_button():
            return False

        if self._last_input_event.button == "3" and self._last_input_event.pressed:
            msg = "INPUT EVENT MANAGER: Last event was secondary click"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return True
        return False

    def last_event_was_secondary_release(self) -> bool:
        """Returns True if the last event is a secondary mouse release."""

        if not self.last_event_was_mouse_button():
            return False

        if self._last_input_event.button == "3" and not self._last_input_event.pressed:
            msg = "INPUT EVENT MANAGER: Last event was secondary release"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return True
        return False


_manager: Optional[InputEventManager] = None
if _manager is None:
    _manager = InputEventManager()

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