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

"""Provides support for defining keybindings and matching them to input
events."""

from __future__ import annotations

__id__        = "$Id$"
__version__   = "$Revision$"
__date__      = "$Date$"
__copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc."
__license__   = "LGPL"

import gi
gi.require_version('Gdk', '3.0')
gi.require_version('Atspi', '2.0') 
from gi.repository import Gdk
from gi.repository import Atspi

import functools
from typing import TYPE_CHECKING

from . import debug
from . import settings
from . import cthulhu_state

from .cthulhu_i18n import _

if TYPE_CHECKING:
    from .input_event import KeyboardEvent, InputEventHandler

_keysymsCache = {}
_keycodeCache = {}

MODIFIER_CTHULHU = 8
NO_MODIFIER_MASK              =  0
ALT_MODIFIER_MASK             =  1 << Atspi.ModifierType.ALT
CTRL_MODIFIER_MASK            =  1 << Atspi.ModifierType.CONTROL
CTHULHU_MODIFIER_MASK            =  1 << MODIFIER_CTHULHU
CTHULHU_ALT_MODIFIER_MASK        = (1 << MODIFIER_CTHULHU |
                                 1 << Atspi.ModifierType.ALT)
CTHULHU_CTRL_MODIFIER_MASK       = (1 << MODIFIER_CTHULHU |
                                 1 << Atspi.ModifierType.CONTROL)
CTHULHU_CTRL_ALT_MODIFIER_MASK   = (1 << MODIFIER_CTHULHU |
                                 1 << Atspi.ModifierType.CONTROL |
                                 1 << Atspi.ModifierType.ALT)
CTHULHU_SHIFT_MODIFIER_MASK      = (1 << MODIFIER_CTHULHU |
                                 1 << Atspi.ModifierType.SHIFT)
SHIFT_MODIFIER_MASK           =  1 << Atspi.ModifierType.SHIFT
SHIFT_ALT_MODIFIER_MASK       = (1 << Atspi.ModifierType.SHIFT |
                                 1 << Atspi.ModifierType.ALT)
CTRL_ALT_MODIFIER_MASK        = (1 << Atspi.ModifierType.CONTROL |
                                 1 << Atspi.ModifierType.ALT)
COMMAND_MODIFIER_MASK         = (1 << Atspi.ModifierType.ALT |
                                 1 << Atspi.ModifierType.CONTROL |
                                 1 << Atspi.ModifierType.META2 |
                                 1 << Atspi.ModifierType.META3)
NON_LOCKING_MODIFIER_MASK     = (1 << Atspi.ModifierType.SHIFT |
                                 1 << Atspi.ModifierType.ALT |
                                 1 << Atspi.ModifierType.CONTROL |
                                 1 << Atspi.ModifierType.META2 |
                                 1 << Atspi.ModifierType.META3 |
                                 1 << MODIFIER_CTHULHU)
defaultModifierMask = NON_LOCKING_MODIFIER_MASK

CAN_USE_KEYSYMS = Atspi.get_version() >= (2, 55, 0)

def get_keycodes(keysym):
    """Converts an XKeysym string to a (keyval, keycode) tuple."""
    if not keysym:
        return (0, 0)

    if keysym not in _keycodeCache:
        keymap = Gdk.Keymap.get_default()
        keyval = Gdk.keyval_from_name(keysym)
        if keyval == 0:
            return (0, 0)

        _keycodeCache[keysym] = (keyval, 0)
        _success, entries = keymap.get_entries_for_keyval(keyval)

        for entry in entries:
            if entry.group == 0:
                _keycodeCache[keysym] = (keyval, entry.keycode)
                break
            if _keycodeCache[keysym] == (keyval, 0):
                _keycodeCache[keysym] = (keyval, entries[0].keycode)

    return _keycodeCache[keysym]

def getKeycode(keysym):
    """Converts an XKeysym string to a keycode. Legacy wrapper."""
    return get_keycodes(keysym)[1]

def getModifierNames(mods):
    """Gets the modifier names of a numeric modifier mask as a human
    consumable string.
    """

    text = ""
    if mods & CTHULHU_MODIFIER_MASK:
        if settings.keyboardLayout == settings.GENERAL_KEYBOARD_LAYOUT_DESKTOP:
            # Translators: this is presented in a GUI to represent the
            # "insert" key when used as the Cthulhu modifier.
            text += _("Insert") + "+"
        else:
            # Translators: this is presented in a GUI to represent the
            # "caps lock" modifier.
            text += _("Caps_Lock") + "+"
    elif mods & (1 << Atspi.ModifierType.SHIFTLOCK):
        # Translators: this is presented in a GUI to represent the
        # "caps lock" modifier.
        #
        text += _("Caps_Lock") + "+"
    #if mods & (1 << Atspi.ModifierType.NUMLOCK):
    #    text += _("Num_Lock") + "+"
    if mods & 128:
        # Translators: this is presented in a GUI to represent the
        # "right alt" modifier.
        #
        text += _("Alt_R") + "+"
    if mods & (1 << Atspi.ModifierType.META3):
        # Translators: this is presented in a GUI to represent the
        # "super" modifier.
        #
        text += _("Super") + "+"
    if mods & (1 << Atspi.ModifierType.META2):
        # Translators: this is presented in a GUI to represent the
        # "meta 2" modifier.
        #
        text += _("Meta2") + "+"
    #if mods & (1 << Atspi.ModifierType.META):
    #    text += _("Meta") + "+"
    if mods & ALT_MODIFIER_MASK:
        # Translators: this is presented in a GUI to represent the
        # "left alt" modifier.
        #
        text += _("Alt_L") + "+"
    if mods & CTRL_MODIFIER_MASK:
        # Translators: this is presented in a GUI to represent the
        # "control" modifier.
        #
        text += _("Ctrl") + "+"
    if mods & SHIFT_MODIFIER_MASK:
        # Translators: this is presented in a GUI to represent the
        # "shift " modifier.
        #
        text += _("Shift") + "+"
    return text

def get_click_countString(count):
    """Returns a human-consumable string representing the number of
    clicks, such as 'double click' and 'triple click'."""

    if count == 2:
        # Translators: Cthulhu keybindings support double
        # and triple "clicks" or key presses, similar to
        # using a mouse.
        #
        return _("double click")
    if count == 3:
        # Translators: Cthulhu keybindings support double
        # and triple "clicks" or key presses, similar to
        # using a mouse.
        #
        return _("triple click")
    return ""

def create_key_definitions(keycode, keyval, modifiers):
    """Returns a list of Atspi key definitions for the given keycode, keyval, and modifiers."""
    ret = []
    if modifiers & CTHULHU_MODIFIER_MASK:
        modifier_list = []
        other_modifiers = modifiers & ~CTHULHU_MODIFIER_MASK
        from . import input_event_manager
        manager = input_event_manager.get_manager()
        for key in settings.cthulhuModifierKeys:
            mod_keyval, mod_keycode = get_keycodes(key)
            if mod_keycode == 0 and key == "Shift_Lock":
                mod_keyval, mod_keycode = get_keycodes("Caps_Lock")
            if CAN_USE_KEYSYMS:
                mod = manager.map_keysym_to_modifier(mod_keyval)
            else:
                mod = manager.map_keycode_to_modifier(mod_keycode)
            if mod:
                modifier_list.append(mod | other_modifiers)
    else:
        modifier_list = [modifiers]
    for mod in modifier_list:
        kd = Atspi.KeyDefinition()
        if CAN_USE_KEYSYMS:
            kd.keysym = keyval
        else:
            kd.keycode = keycode
        kd.modifiers = mod
        ret.append(kd)
    return ret

class KeyBinding:
    """A single key binding, consisting of a keycode, a modifier mask,
    and the InputEventHandler.
    """

    def __init__(self, keysymstring, modifier_mask, modifiers, handler,
                 click_count = 1, enabled=True):
        """Creates a new key binding.

        Arguments:
        - keysymstring: the keysymstring - this is typically a string
          from /usr/include/X11/keysymdef.h with the preceding 'XK_'
          removed (e.g., XK_KP_Enter becomes the string 'KP_Enter').
        - modifier_mask: bit mask where a set bit tells us what modifiers
          we care about (see Atspi.ModifierType.*)
        - modifiers: the state the modifiers we care about must be in for
          this key binding to match an input event (see also
          Atspi.ModifierType.*)
        - handler: the InputEventHandler for this key binding
        """

        self.keysymstring = keysymstring
        self.modifier_mask = modifier_mask
        self.modifiers = modifiers
        self.handler = handler
        self.click_count = click_count
        self.keycode = 0
        self.keyval = 0
        self._enabled = enabled
        self._grab_ids = []

    def matches(self, keyval, keycode, modifiers):
        """Returns true if this key binding matches the given keyval/keycode and modifier state."""

        if not self.keycode:
            self.keyval, self.keycode = get_keycodes(self.keysymstring)

        if self.keycode == keycode or self.keyval == keyval:
            result = modifiers & self.modifier_mask
            return result == self.modifiers

        return False

    def description(self):
        """Returns the description of this binding's functionality."""

        try:
            return self.handler.description
        except Exception:
            return ''

    def asString(self):
        """Returns a more human-consumable string representing this binding."""

        mods = getModifierNames(self.modifiers)
        clickCount = get_click_countString(self.click_count)
        keysym = self.keysymstring
        string = f'{mods}{keysym} {clickCount}'

        return string.strip()

    def is_bound(self):
        """Returns True if this KeyBinding is bound to a key."""
        return bool(self.keysymstring)

    def is_enabled(self):
        """Returns True if this KeyBinding is enabled."""
        return self._enabled

    def set_enabled(self, enabled):
        """Set this KeyBinding's enabled state."""
        self._enabled = enabled

    def get_grab_ids(self):
        """Returns the grab IDs for this KeyBinding."""
        return self._grab_ids

    def has_grabs(self):
        """Returns True if there are existing grabs associated with this KeyBinding."""
        return bool(self._grab_ids)

    def add_grabs(self):
        """Adds key grabs for this KeyBinding."""
        from . import input_event_manager
        self._grab_ids = input_event_manager.get_manager().add_grabs_for_keybinding(self)

    def remove_grabs(self):
        """Removes key grabs for this KeyBinding."""
        from . import input_event_manager
        input_event_manager.get_manager().remove_grabs_for_keybinding(self)
        self._grab_ids = []

    def key_definitions(self):
        """Return a list of Atspi key definitions for the given binding."""
        ret = []
        if not self.keycode:
            self.keyval, self.keycode = get_keycodes(self.keysymstring)
        ret.extend(create_key_definitions(self.keycode, self.keyval, self.modifiers))
        if CAN_USE_KEYSYMS and self.modifiers & SHIFT_MODIFIER_MASK:
            upper_keyval = Gdk.keyval_to_upper(self.keyval)
            if upper_keyval != self.keyval:
                ret.extend(create_key_definitions(self.keycode, upper_keyval, self.modifiers))
        return ret

    def keyDefs(self):
        """Legacy wrapper. Use key_definitions() instead."""
        return self.key_definitions()

class KeyBindings:
    """Structure that maintains a set of KeyBinding instances.
    """

    def __init__(self):
        self.keyBindings = []

    def __str__(self):
        result = "[\n"
        for keyBinding in self.keyBindings:
            result += "  [%x %x %s %d %s]\n" % \
                      (keyBinding.modifier_mask,
                       keyBinding.modifiers,
                       keyBinding.keysymstring,
                       keyBinding.click_count,
                       keyBinding.handler.description)
        result += "]"
        return result

    def add(self, keyBinding, include_grabs=False):
        """Adds the given KeyBinding instance to this set of keybindings.
        """

        if keyBinding.keysymstring and self.hasKeyBinding(keyBinding, "keysNoMask"):
            msg = (
               f"KEYBINDINGS: '{keyBinding.asString()}' "
               f"({keyBinding.description()}) already in keybindings"
            )
            debug.printMessage(debug.LEVEL_INFO, msg, True)

        self.keyBindings.append(keyBinding)
        if include_grabs:
            keyBinding.add_grabs()

    def remove(self, keyBinding, include_grabs=False):
        """Removes the given KeyBinding instance from this set of keybindings.
        """

        if keyBinding not in self.keyBindings:
            candidates = self.getBindingsForHandler(keyBinding.handler)
            if not candidates:
                return
            for candidate in self.getBindingsForHandler(keyBinding.handler):
                self.remove(candidate, include_grabs)
            return

        if keyBinding.has_grabs():
            if include_grabs:
                keyBinding.remove_grabs()

        try:
            self.keyBindings.remove(keyBinding)
        except ValueError:
            pass

    def removeByHandler(self, handler):
        """Removes the given KeyBinding instance from this set of keybindings.
        """
        i = len(self.keyBindings)
        while i > 0:
            if self.keyBindings[i - 1].handler == handler:
                del self.keyBindings[i - 1]
            i = i - 1

    def add_key_grabs(self, reason=""):
        """Adds grabs for all enabled bindings in this set of keybindings."""
        msg = "KEYBINDINGS: Adding key grabs"
        if reason:
            msg += f": {reason}"
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        count = 0
        for binding in self.keyBindings:
            if binding.is_enabled() and not binding.has_grabs():
                count += 1
                binding.add_grabs()

        msg = f"KEYBINDINGS: {count} key grabs added (total bindings: {len(self.keyBindings)})."
        debug.printMessage(debug.LEVEL_INFO, msg, True)

    def remove_key_grabs(self, reason=""):
        """Removes all grabs for this set of keybindings."""
        msg = "KEYBINDINGS: Removing key grabs"
        if reason:
            msg += f": {reason}"
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        count = 0
        for binding in self.keyBindings:
            if binding.has_grabs():
                count += 1
                binding.remove_grabs()

        msg = f"KEYBINDINGS: {count} key grabs removed (total bindings: {len(self.keyBindings)})."
        debug.printMessage(debug.LEVEL_INFO, msg, True)

    def hasKeyBinding (self, newKeyBinding, typeOfSearch="strict"):
        """Return True if keyBinding is already in self.keyBindings.

           The typeOfSearch can be:
              "strict":      matches description, modifiers, key, and
                             click count
              "description": matches only description.
              "keys":        matches the modifiers, key, and modifier mask,
                             and click count
              "keysNoMask":  matches the modifiers, key, and click count
        """

        hasIt = False

        for keyBinding in self.keyBindings:
            if typeOfSearch == "strict":
                if (keyBinding.handler.description \
                    == newKeyBinding.handler.description) \
                    and (keyBinding.keysymstring \
                         == newKeyBinding.keysymstring) \
                    and (keyBinding.modifier_mask \
                         == newKeyBinding.modifier_mask) \
                    and (keyBinding.modifiers \
                         == newKeyBinding.modifiers) \
                    and (keyBinding.click_count \
                         == newKeyBinding.click_count):
                    hasIt = True
            elif typeOfSearch == "description":
                if keyBinding.handler.description \
                    == newKeyBinding.handler.description:
                    hasIt = True
            elif typeOfSearch == "keys":
                if (keyBinding.keysymstring \
                    == newKeyBinding.keysymstring) \
                    and (keyBinding.modifier_mask \
                         == newKeyBinding.modifier_mask) \
                    and (keyBinding.modifiers \
                         == newKeyBinding.modifiers) \
                    and (keyBinding.click_count \
                         == newKeyBinding.click_count):
                    hasIt = True
            elif typeOfSearch == "keysNoMask":
                if (keyBinding.keysymstring \
                    == newKeyBinding.keysymstring) \
                    and (keyBinding.modifiers \
                         == newKeyBinding.modifiers) \
                    and (keyBinding.click_count \
                         == newKeyBinding.click_count):
                    hasIt = True

        return hasIt

    def getBoundBindings(self, uniqueOnly=False):
        """Returns the KeyBinding instances which are bound to a keystroke.

        Arguments:
        - uniqueOnly: Should alternative bindings for the same handler be
          filtered out (default: False)
        """

        bound = [kb for kb in self.keyBindings if kb.keysymstring]
        if uniqueOnly:
            handlers = [kb.handler.description for kb in bound]
            bound = [bound[i] for i in map(handlers.index, set(handlers))]

        bindings = {}
        for kb in bound:
            string = kb.asString()
            match = bindings.get(string)
            if match is not None:
                tokens = ["WARNING: '", string, "' (", kb.description(), ") also matches:", match]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
            bindings[string] = kb.description()

        return bound

    def getBindingsForHandler(self, handler):
        """Returns the KeyBinding instances associated with handler."""

        return [kb for kb in self.keyBindings if kb.handler == handler]

    def has_enabled_handler(self, handler):
        """Returns True if the handler is found in this set of keybindings and is enabled."""
        for binding in self.keyBindings:
            if binding.handler == handler and binding.is_enabled():
                return True
        return False

    def _checkMatchingBindings(self, keyboardEvent, result):
        if debug.debugLevel > debug.LEVEL_INFO:
            return

        # If we don't have multiple matches, we're good.
        if len(result) <= 1:
            return

        # If we have multiple matches, but they have unique click counts, we're good.
        if len(set(map(lambda x: x.click_count, result))) == len(result):
            return

        def toString(x):
            return "%s (%ix)" % (x.handler.description, x.click_count)

        msg = (
            f"KEYBINDINGS: '{keyboardEvent.event_string}' "
            f"matches multiple handlers: {', '.join(map(toString, result))}"
        )
        debug.printMessage(debug.LEVEL_INFO, msg, True)

    def getInputHandler(self, keyboardEvent):
        """Returns the input handler of the key binding that matches the
        given keycode and modifiers, or None if no match exists.
        """

        matches = []
        candidates = []
        clickCount = keyboardEvent.get_click_count()
        for keyBinding in self.keyBindings:
            if keyBinding.matches(keyboardEvent.id, keyboardEvent.hw_code, keyboardEvent.modifiers):
                if (keyboardEvent.modifiers & keyBinding.modifier_mask) == keyBinding.modifiers and \
                keyBinding.click_count == clickCount:
                    matches.append(keyBinding)
                # If there's no keysymstring, it's unbound and cannot be
                # a match.
                #
                if keyBinding.keysymstring:
                    candidates.append(keyBinding)

        self._checkMatchingBindings(keyboardEvent, matches)
        if matches:
            return matches[0].handler

        if keyboardEvent.isKeyPadKeyWithNumlockOn():
            return None

        # If we're still here, we don't have an exact match. Prefer
        # the one whose click count is closest to, but does not exceed,
        # the actual click count.
        #
        candidates.sort(key=functools.cmp_to_key(lambda x, y: y.click_count - x.click_count))
        self._checkMatchingBindings(keyboardEvent, candidates)
        for candidate in candidates:
            if candidate.click_count <= clickCount:
                return candidate.handler

        return None

    def load(self, keymap, handlers):
        """ Takes the keymappings and tries to find a matching named
           function in handlers.
           keymap is a list of lists, each list contains 5 elements
           If addUnbound is set to true, then at the end of loading all the
           keybindings, any remaining functions will be unbound.
        """

        # TODO - JD: This won't be needed once the remaining bindings have
        # been removed from the keymap files.

        for i in keymap:
            keysymstring = i[0]
            modifierMask = i[1]
            modifiers = i[2]
            handler = i[3]
            try:
                clickCount = i[4]
            except Exception:
                clickCount = 1

            if handler in handlers:
                self.add(KeyBinding(
                    keysymstring, modifierMask, modifiers, handlers[handler], clickCount))
            else:
                tokens = ["KEYBINDINGS: Could not find", handler, "handler for keybinding."]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
