import re
from abc import ABCMeta, abstractmethod
from pathlib import Path
from subprocess import CalledProcessError, check_output

from libqtile.command.base import expose_command
from libqtile.confreader import ConfigError
from libqtile.core.manager import Qtile
from libqtile.log_utils import logger
from libqtile.widget import base


class _BaseLayoutBackend(metaclass=ABCMeta):
    def __init__(self, qtile: Qtile):
        """
        This handles getting and setter the keyboard layout with the appropriate
        backend.
        """

    @abstractmethod
    def get_keyboard(self) -> str:
        """
        Return the currently used keyboard layout as a string

        Examples: "us", "us dvorak".  In case of error returns "unknown".
        """

    def set_keyboard(self, layout: str, options: str | None) -> None:
        """
        Set the keyboard layout with specified options.
        """


class _X11LayoutBackend(_BaseLayoutBackend):
    kb_layout_regex = re.compile(r"layout:\s+(?P<layout>[\w-]+)")
    kb_variant_regex = re.compile(r"variant:\s+(?P<variant>[\w-]+)")

    def get_keyboard(self) -> str:
        try:
            command = "setxkbmap -verbose 10 -query"
            setxkbmap_output = check_output(command.split(" ")).decode()
        except CalledProcessError:
            logger.exception("Can not get the keyboard layout:")
            return "unknown"
        except OSError:
            logger.exception("Please, check that xset is available:")
            return "unknown"

        match_layout = self.kb_layout_regex.search(setxkbmap_output)
        if match_layout is None:
            return "ERR"
        keyboard = match_layout.group("layout")

        match_variant = self.kb_variant_regex.search(setxkbmap_output)
        if match_variant:
            keyboard += " " + match_variant.group("variant")
        return keyboard

    def set_keyboard(self, layout: str, options: str | None) -> None:
        command = ["setxkbmap"]
        command.extend(layout.split(" "))
        if options:
            command.extend(["-option", options])
        try:
            check_output(command)
        except CalledProcessError:
            logger.error("Cannot change the keyboard layout.")
        except OSError:
            logger.error("Please, check that setxkbmap is available.")
        else:
            # Load Xmodmap if it's available
            if Path("~/.Xmodmap").expanduser().is_file():
                try:
                    check_output("xmodmap $HOME/.Xmodmap", shell=True)
                except CalledProcessError:
                    logger.error("Could not load ~/.Xmodmap.")


class _WaylandLayoutBackend(_BaseLayoutBackend):
    def __init__(self, qtile: Qtile) -> None:
        from libqtile.backend.wayland import core as waylandcore

        assert isinstance(qtile.core, waylandcore.Core)
        self.set_keymap = qtile.core.set_keymap
        self._layout: str = ""

    def get_keyboard(self) -> str:
        return self._layout

    def set_keyboard(self, layout: str, options: str | None) -> None:
        maybe_variant: str | None = None
        if " " in layout:
            layout_name, maybe_variant = layout.split(" ", maxsplit=1)
        else:
            layout_name = layout
        self.set_keymap(layout_name, options, maybe_variant)
        self._layout = layout


layout_backends = {
    "x11": _X11LayoutBackend,
    "wayland": _WaylandLayoutBackend,
}


class KeyboardLayout(base.InLoopPollText):
    """Widget for changing and displaying the current keyboard layout

    To use this widget effectively you need to specify keyboard layouts you want to use
    (using "configured_keyboards") and bind function "next_keyboard" to specific keys in
    order to change layouts.

    For example:

        Key([mod], "space", lazy.widget["keyboardlayout"].next_keyboard(), desc="Next keyboard layout."),

    When running Qtile with the X11 backend, this widget requires setxkbmap to be available.
    Xmodmap will also be used if .Xmodmap file is available.
    """

    defaults = [
        ("update_interval", 1, "Update time in seconds."),
        (
            "configured_keyboards",
            ["us"],
            "A list of predefined keyboard layouts "
            "represented as strings. For example: "
            "['us', 'us colemak', 'es', 'fr'].",
        ),
        (
            "display_map",
            {},
            "Custom display of layout. Key should be in format "
            "'layout variant'. For example: "
            "{'us': 'us', 'lt sgs': 'sgs', 'ru phonetic': 'ru'}",
        ),
        ("option", None, "string of setxkbmap option. Ex., 'compose:menu,grp_led:scroll'"),
    ]

    def __init__(self, **config):
        base.InLoopPollText.__init__(self, **config)
        self.add_defaults(KeyboardLayout.defaults)
        self.add_callbacks({"Button1": self.next_keyboard})

    def _configure(self, qtile, bar):
        base.InLoopPollText._configure(self, qtile, bar)

        if qtile.core.name not in layout_backends:
            raise ConfigError("KeyboardLayout does not support backend: " + qtile.core.name)

        self.backend = layout_backends[qtile.core.name](qtile)
        self.backend.set_keyboard(self.configured_keyboards[0], self.option)

    @expose_command()
    def next_keyboard(self):
        """set the next layout in the list of configured keyboard layouts as
        new current layout in use

        If the current keyboard layout is not in the list, it will set as new
        layout the first one in the list.
        """

        current_keyboard = self.backend.get_keyboard()
        if current_keyboard in self.configured_keyboards:
            # iterate the list circularly
            next_keyboard = self.configured_keyboards[
                (self.configured_keyboards.index(current_keyboard) + 1)
                % len(self.configured_keyboards)
            ]
        else:
            next_keyboard = self.configured_keyboards[0]

        self.backend.set_keyboard(next_keyboard, self.option)

        self.tick()

    def poll(self):
        keyboard = self.backend.get_keyboard()
        if keyboard in self.display_map.keys():
            return self.display_map[keyboard]
        return keyboard.upper()
